diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 21:21:58 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 21:30:04 +0300 |
| commit | 420925d141c8805e610eb0d62d49d8b9b483d961 (patch) | |
| tree | bbd1f778a038bf4387c704a0df921fa53e7af615 | |
| parent | 43c6ca3a5dbdcefc6c6dc29be9ce81d990baf39e (diff) | |
fix: support scrolling direct agent attaches
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/next/README.md | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/agents.mdx | 2 | ||||
| -rw-r--r-- | src/client/mod.rs | 221 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 49 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 31 | ||||
| -rw-r--r-- | src/server/headless.rs | 456 | ||||
| -rw-r--r-- | tests/api_ping.rs | 2 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 12 | ||||
| -rw-r--r-- | tests/client_mode.rs | 36 | ||||
| -rw-r--r-- | tests/cross_area.rs | 16 | ||||
| -rw-r--r-- | tests/detach_reattach.rs | 44 | ||||
| -rw-r--r-- | tests/multi_client.rs | 2 | ||||
| -rw-r--r-- | tests/server_headless.rs | 14 |
14 files changed, 772 insertions, 116 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 1fff74cf..a58d4f61 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added a session navigator at `prefix+g` with a searchable workspace/tab/pane tree, agent state filters, mouse switching, and keyboard navigation. (#157) +- Added scrollback support to direct agent terminal attaches. Mouse wheel and plain PageUp/PageDown now scroll the attached terminal viewport, while terminal apps that request mouse or alternate-scroll input still receive those events. The client/server protocol is now version 11. ## [0.6.2] - 2026-05-23 diff --git a/docs/next/README.md b/docs/next/README.md index 5a447d8c..d2fcb7e1 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -114,7 +114,7 @@ same session, same agents, same state. `herdr` and `herdr --remote` attach to the full herdr session ui. `herdr agent attach <target>` attaches your current terminal directly to one server-owned terminal, like a single-pane terminal attach. `herdr terminal attach <terminal_id>` does the same by terminal id. -direct attach streams the current rendered terminal state first, then live ansi frames. your input goes straight to that terminal. detach with `ctrl+b q`; send a literal `ctrl+b` with `ctrl+b ctrl+b`. one writable client owns input and resize for a terminal. a second attach fails unless you pass `--takeover`. +direct attach streams the current rendered terminal state first, then live ansi frames. your input goes straight to that terminal. scroll with the mouse wheel or plain page up/page down; normal input jumps back to the bottom. detach with `ctrl+b q`; send a literal `ctrl+b` with `ctrl+b ctrl+b`. one writable client owns input and resize for a terminal. a second attach fails unless you pass `--takeover`. ## agent awareness diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index d6b3f7f2..6605923c 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -125,6 +125,8 @@ herdr agent attach reviewer Detach with `ctrl+b q`. Send a literal `ctrl+b` with `ctrl+b ctrl+b`. +Scroll with the mouse wheel or plain page up/page down. Normal input jumps back to the bottom. + Use `--takeover` if another direct attach client already owns input: ```bash diff --git a/src/client/mod.rs b/src/client/mod.rs index 1ac05201..5dd2652f 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -23,16 +23,17 @@ use std::time::Duration; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + EnableFocusChange, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::execute; use tracing::{debug, info, warn}; use crate::protocol::render_ansi; use crate::protocol::{ - self, ClientKeybindings, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, - MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, NotifyKind, + RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; use crate::server::socket_paths::client_socket_path; @@ -56,6 +57,8 @@ struct ClientState { kitty_graphics_enabled: bool, /// Direct attach prefix escape state. None for full-app clients. attach_escape: Option<AttachEscapeState>, + /// Rows scrolled for one direct-attach wheel notch. + mouse_scroll_lines: usize, } #[derive(Debug, Default)] @@ -66,12 +69,25 @@ struct AttachEscapeState { #[derive(Debug)] enum AttachInputAction { Forward(Vec<u8>), + Scroll { + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + }, Detach, None, } impl AttachEscapeState { - fn filter_input(&mut self, data: Vec<u8>) -> AttachInputAction { + fn filter_input( + &mut self, + data: Vec<u8>, + viewport_rows: u16, + mouse_scroll_lines: usize, + ) -> AttachInputAction { const PREFIX: u8 = 0x02; // Ctrl+B let mut output = Vec::with_capacity(data.len()); @@ -98,12 +114,73 @@ impl AttachEscapeState { if output.is_empty() { AttachInputAction::None + } else if let Some(action) = + attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) + { + action } else { AttachInputAction::Forward(output) } } } +fn attach_scroll_action( + data: &[u8], + viewport_rows: u16, + mouse_scroll_lines: usize, +) -> Option<AttachInputAction> { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + + match events.pop()? { + crate::raw_input::RawInputEvent::Mouse(mouse) => { + let direction = match mouse.kind { + MouseEventKind::ScrollUp => AttachScrollDirection::Up, + MouseEventKind::ScrollDown => AttachScrollDirection::Down, + _ => return Some(AttachInputAction::None), + }; + Some(AttachInputAction::Scroll { + source: AttachScrollSource::Wheel, + direction, + lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + column: Some(mouse.column), + row: Some(mouse.row), + modifiers: mouse.modifiers.bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + let direction = match key.code { + KeyCode::PageUp => AttachScrollDirection::Up, + KeyCode::PageDown => AttachScrollDirection::Down, + _ => return None, + }; + Some(AttachInputAction::Scroll { + source: AttachScrollSource::PageKey { + input: data.to_vec(), + }, + direction, + lines: viewport_rows.saturating_sub(1).max(1), + column: None, + row: None, + modifiers: KeyModifiers::empty().bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && key.kind == KeyEventKind::Release + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => + { + Some(AttachInputAction::None) + } + _ => None, + } +} + impl ClientState { fn request_full_redraw(&mut self) { self.blit_encoder = render_ansi::BlitEncoder::new(); @@ -210,10 +287,11 @@ fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { /// Sets up a direct attach terminal. /// -/// Direct attach forwards stdin to the attached PTY, so it must not enable -/// outer-terminal protocols that generate local responses or mouse reports. +/// Direct attach forwards stdin to the attached PTY. It enables mouse capture +/// so wheel events can drive the attached viewport or be forwarded to child +/// programs that requested mouse input. fn setup_direct_attach_terminal() -> io::Result<TerminalGuard> { - setup_terminal_with_capabilities(false, false) + setup_terminal_with_capabilities(false, true) } fn setup_terminal_with_capabilities( @@ -234,6 +312,8 @@ fn setup_terminal_with_capabilities( EnableFocusChange, PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) )?; + } else if mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; } else { execute!(io::stdout(), DisableMouseCapture)?; } @@ -433,6 +513,7 @@ fn run_client_with_mode( init_logging(); let loaded_config = crate::config::Config::load(); + let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); let sound_config = loaded_config.config.ui.sound; let direct_attach_requested = attach_request.is_some(); let kitty_graphics_enabled = @@ -526,6 +607,7 @@ fn run_client_with_mode( rows, should_quit, sound_config, + mouse_scroll_lines, kitty_graphics_enabled, false, negotiated_encoding, @@ -572,6 +654,7 @@ async fn run_client_loop( rows: u16, should_quit: Arc<AtomicBool>, sound_config: crate::config::SoundConfig, + mouse_scroll_lines: usize, kitty_graphics_enabled: bool, mouse_capture_active: bool, negotiated_encoding: RenderEncoding, @@ -584,6 +667,7 @@ async fn run_client_loop( sound_config, kitty_graphics_enabled, attach_escape, + mouse_scroll_lines, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -644,8 +728,33 @@ async fn run_client_loop( match event { ClientLoopEvent::StdinInput(data) => { let data = if let Some(attach_escape) = &mut state.attach_escape { - match attach_escape.filter_input(data) { + match attach_escape.filter_input( + data, + state.reported_size.1, + state.mouse_scroll_lines, + ) { AttachInputAction::Forward(data) => data, + AttachInputAction::Scroll { + source, + direction, + lines, + column, + row, + modifiers, + } => { + let msg = ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }; + if let Err(e) = write_to_server(&mut write_stream, &msg) { + return Err(ClientError::ConnectionLost(e)); + } + continue; + } AttachInputAction::Detach => { let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); return Ok(()); @@ -1269,11 +1378,11 @@ mod tests { fn attach_escape_detaches_on_prefix_q() { let mut escape = AttachEscapeState::default(); assert!(matches!( - escape.filter_input(vec![0x02]), + escape.filter_input(vec![0x02], 24, 3), AttachInputAction::None )); assert!(matches!( - escape.filter_input(vec![b'q']), + escape.filter_input(vec![b'q'], 24, 3), AttachInputAction::Detach )); } @@ -1282,10 +1391,10 @@ mod tests { fn attach_escape_sends_literal_prefix_on_double_prefix() { let mut escape = AttachEscapeState::default(); assert!(matches!( - escape.filter_input(vec![0x02]), + escape.filter_input(vec![0x02], 24, 3), AttachInputAction::None )); - match escape.filter_input(vec![0x02]) { + match escape.filter_input(vec![0x02], 24, 3) { AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), other => panic!("expected forwarded prefix, got {other:?}"), } @@ -1295,16 +1404,98 @@ mod tests { fn attach_escape_forwards_prefix_before_non_escape_key() { let mut escape = AttachEscapeState::default(); assert!(matches!( - escape.filter_input(vec![b'a', 0x02]), + escape.filter_input(vec![b'a', 0x02], 24, 3), AttachInputAction::Forward(bytes) if bytes == b"a" )); - match escape.filter_input(vec![b'x']) { + match escape.filter_input(vec![b'x'], 24, 3) { AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), other => panic!("expected forwarded bytes, got {other:?}"), } } #[test] + fn attach_escape_turns_wheel_into_scroll_action() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { + AttachInputAction::Scroll { + source, + direction, + lines, + column, + row, + .. + } => { + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 7); + assert_eq!(column, Some(10)); + assert_eq!(row, Some(5)); + } + other => panic!("expected scroll action, got {other:?}"), + } + } + + #[test] + fn attach_escape_swallows_non_wheel_mouse_reports() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::None + )); + } + + #[test] + fn attach_escape_turns_plain_page_keys_into_scroll_actions() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { + AttachInputAction::Scroll { + source, + direction, + lines, + .. + } => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 11); + } + other => panic!("expected page-up scroll action, got {other:?}"), + } + + match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { + AttachInputAction::Scroll { + source, + direction, + lines, + .. + } => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[6~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Down); + assert_eq!(lines, 11); + } + other => panic!("expected page-down scroll action, got {other:?}"), + } + } + + #[test] + fn attach_escape_forwards_modified_page_key() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), + other => panic!("expected modified page key to forward, got {other:?}"), + } + } + + #[test] fn client_error_display_connection_failed() { let err = ClientError::ConnectionFailed(io::Error::new( io::ErrorKind::ConnectionRefused, diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index ea81b36e..bd79c900 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 10; +pub const PROTOCOL_VERSION: u32 = 11; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -109,6 +109,37 @@ pub enum ClientMessage { /// Replace an existing writable attach owner for this terminal. takeover: bool, }, + + /// Scroll input handled by a direct terminal attach client. + AttachScroll { + /// Original input source for routing. + source: AttachScrollSource, + /// Scroll direction. + direction: AttachScrollDirection, + /// Number of terminal rows to move when using host scrollback. + lines: u16, + /// Mouse column relative to the attached terminal, when available. + column: Option<u16>, + /// Mouse row relative to the attached terminal, when available. + row: Option<u16>, + /// Crossterm-compatible modifier bits for forwarded mouse wheel events. + modifiers: u8, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AttachScrollDirection { + Up, + Down, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AttachScrollSource { + Wheel, + PageKey { + /// Original key bytes to forward when the child application owns page keys. + input: Vec<u8>, + }, } // --------------------------------------------------------------------------- @@ -702,6 +733,22 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn client_attach_scroll_roundtrip() { + let msg = ClientMessage::AttachScroll { + source: AttachScrollSource::Wheel, + direction: AttachScrollDirection::Up, + lines: 3, + column: Some(12), + row: Some(7), + modifiers: 4, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + // ---- Round-trip: ServerMessage ---- #[test] diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 5a5984aa..36334fda 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -14,8 +14,9 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use crate::protocol::{ - self, ClientKeybindings, ClientMessage, RenderEncoding, ServerMessage, - MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, + RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -72,6 +73,16 @@ pub(crate) enum ServerEvent { terminal_id: String, takeover: bool, }, + /// A direct terminal attach client requested scrollback movement. + ClientAttachScroll { + client_id: u64, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + }, /// A client sent a resize message. ClientResize { client_id: u64, @@ -431,6 +442,22 @@ fn client_read_loop( terminal_id, takeover, }, + ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + } => ServerEvent::ClientAttachScroll { + client_id, + source, + direction, + lines, + column, + row, + modifiers, + }, ClientMessage::Hello { .. } => { // Duplicate Hello — ignore. continue; diff --git a/src/server/headless.rs b/src/server/headless.rs index a6d3f6a5..d10aef04 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -23,6 +23,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; +use crossterm::event::{KeyModifiers, MouseEventKind}; use ratatui::layout::Rect; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -34,7 +35,10 @@ use crate::api; use crate::app; use crate::config; use crate::events::AppEvent; -use crate::protocol::{self, FrameData, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE}; +use crate::protocol::{ + self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, +}; use crate::server::client_accept::accept_pending_client_connections; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ @@ -125,6 +129,79 @@ pub struct HeadlessServer { server_event_tx: mpsc::Sender<ServerEvent>, } +fn apply_terminal_attach_scroll( + runtime: &crate::terminal::TerminalRuntime, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, +) -> Result<(), String> { + let wheel_kind = match direction { + AttachScrollDirection::Up => MouseEventKind::ScrollUp, + AttachScrollDirection::Down => MouseEventKind::ScrollDown, + }; + if let AttachScrollSource::PageKey { input } = source { + let host_scroll = runtime.input_state().is_some_and(|input_state| { + !input_state.alternate_screen && !input_state.mouse_reporting_enabled() + }); + if host_scroll { + match direction { + AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), + AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), + } + return Ok(()); + } + return apply_terminal_attach_input(runtime, input); + } + + match runtime.wheel_routing() { + Some(crate::pane::WheelRouting::MouseReport) => { + runtime.scroll_reset(); + let column = column.unwrap_or(0); + let row = row.unwrap_or(0); + let Some(bytes) = runtime.encode_mouse_wheel( + wheel_kind, + column, + row, + KeyModifiers::from_bits_truncate(modifiers), + ) else { + return Err(format!( + "failed to encode terminal attach mouse wheel event: {wheel_kind:?}" + )); + }; + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("terminal attach mouse wheel input failed: {err}"))?; + } + Some(crate::pane::WheelRouting::AlternateScroll) => { + runtime.scroll_reset(); + let Some(bytes) = runtime.encode_alternate_scroll(wheel_kind) else { + return Ok(()); + }; + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("terminal attach alternate scroll input failed: {err}"))?; + } + Some(crate::pane::WheelRouting::HostScroll) | None => match direction { + AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), + AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), + }, + } + Ok(()) +} + +fn apply_terminal_attach_input( + runtime: &crate::terminal::TerminalRuntime, + data: Vec<u8>, +) -> Result<(), String> { + runtime.scroll_reset(); + runtime + .try_send_bytes(Bytes::from(data)) + .map_err(|err| format!("terminal attach input failed: {err}")) +} + impl HeadlessServer { /// Creates and starts the headless server. /// @@ -546,6 +623,24 @@ impl HeadlessServer { } } + fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool { + if self.foreground_client_id == Some(client_id) { + return true; + } + matches!( + self.clients.get(&client_id).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalAttach { .. }) + ) && self.foreground_client_id.is_some() + } + + fn remove_client_and_resize_if_needed(&mut self, client_id: u64) { + let needs_shared_resize = self.client_removal_needs_shared_resize(client_id); + let foreground_changed = self.remove_client(client_id); + if needs_shared_resize || foreground_changed { + self.resize_shared_runtime_to_effective_size(); + } + } + fn send_client_graphics_cleanup(&mut self, client_id: u64) { let (writer, bytes) = match self.clients.get_mut(&client_id) { Some(client) => { @@ -690,6 +785,35 @@ impl HeadlessServer { true } + fn handle_terminal_attach_scroll( + &mut self, + client_id: u64, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + ) -> bool { + let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = self.clients.get(&client_id) + else { + return false; + }; + let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) else { + return false; + }; + + if let Err(err) = + apply_terminal_attach_scroll(runtime, source, direction, lines, column, row, modifiers) + { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach scroll failed"); + } + true + } + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { self.app .state @@ -1058,10 +1182,7 @@ impl HeadlessServer { // Remove broken clients. for client_id in broken_clients { - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -1091,10 +1212,7 @@ impl HeadlessServer { client_id, "client writer channel closed during targeted send" ); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); return false; } } @@ -1114,10 +1232,7 @@ impl HeadlessServer { reason: Some(reason.clone()), }, ); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -1136,7 +1251,7 @@ impl HeadlessServer { )), }, ); - self.remove_client(client_id); + self.remove_client_and_resize_if_needed(client_id); return false; }; @@ -1150,7 +1265,7 @@ impl HeadlessServer { )), }, ); - self.remove_client(client_id); + self.remove_client_and_resize_if_needed(client_id); return false; } if existing_owner != client_id { @@ -1160,7 +1275,7 @@ impl HeadlessServer { reason: Some("terminal attach taken over".to_owned()), }, ); - self.remove_client(existing_owner); + self.remove_client_and_resize_if_needed(existing_owner); } } @@ -1243,6 +1358,17 @@ impl HeadlessServer { terminal_id, takeover, } => self.attach_terminal_client(client_id, terminal_id, takeover), + ServerEvent::ClientAttachScroll { + client_id, + source, + direction, + lines, + column, + row, + modifiers, + } => self.handle_terminal_attach_scroll( + client_id, source, direction, lines, column, row, modifiers, + ), ServerEvent::ClientInput { client_id, data } => { debug!(client_id, len = data.len(), "client input received"); if let Some(ClientConnection { @@ -1251,8 +1377,8 @@ impl HeadlessServer { }) = self.clients.get(&client_id) { if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { - if let Err(err) = runtime.try_send_bytes(Bytes::from(data)) { - warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach input failed"); + if let Err(err) = apply_terminal_attach_input(runtime, data) { + warn!(client_id, terminal_id = %terminal_id, err = %err); } } return true; @@ -1395,18 +1521,12 @@ impl HeadlessServer { } ServerEvent::ClientDetach { client_id } => { info!(client_id, "client detached"); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); true } ServerEvent::ClientDisconnected { client_id } => { info!(client_id, "client disconnected"); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); true } ServerEvent::ClientWriterDrained { client_id } => { @@ -1689,10 +1809,7 @@ impl HeadlessServer { } for client_id in broken_clients { - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -1890,10 +2007,7 @@ impl HeadlessServer { if !broken_clients.is_empty() { for client_id in broken_clients { - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -2646,6 +2760,181 @@ next_tab = "" } #[test] + fn terminal_attach_scroll_moves_attached_runtime_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let runtime = + crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Up, + 3, + None, + None, + 0, + ) + .expect("scroll up"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Down, + 2, + None, + None, + 0, + ) + .expect("scroll down"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 1); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] + fn terminal_attach_input_resets_scrolled_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + + runtime.scroll_up(4); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + + apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded input"), + Bytes::from("x") + ); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] + fn terminal_attach_page_key_host_scrolls_plain_terminal() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key scroll"); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] + fn terminal_attach_page_key_forwards_when_mouse_reporting() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = b"\x1b[?1000h".to_vec(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + runtime.scroll_up(3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key forward"); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); @@ -3264,6 +3553,105 @@ next_tab = "" } #[test] + fn terminal_attach_disconnect_restores_app_pane_size() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + let expected_app_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + assert_ne!(expected_app_size, (24, 80)); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 2, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::TerminalAnsi, + keybindings: None, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 2, + terminal_id: terminal_id_string.clone(), + takeover: false, + }) + ); + assert_eq!(server.foreground_client_id, Some(1)); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + (24, 80) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); + + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + expected_app_size + ); + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); diff --git a/tests/api_ping.rs b/tests/api_ping.rs index b1468ae4..a124fb4a 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -227,7 +227,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 10); + assert_eq!(value["result"]["protocol"], 11); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index f9084f6a..4a281c79 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -1109,7 +1109,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 10"), + full_stdout.contains(" protocol: 11"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -1142,7 +1142,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 10"), + server_stdout.contains("protocol: 11"), "stdout: {server_stdout}" ); @@ -1154,7 +1154,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 10"), + client_stdout.contains("protocol: 11"), "stdout: {client_stdout}" ); assert!( @@ -1164,7 +1164,7 @@ fn status_commands_report_client_and_server_versions() { let full_json = run_cli_json(&socket_path, &["status", "--json"]); assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 10); + assert_eq!(full_json["client"]["protocol"], 11); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -1178,12 +1178,12 @@ fn status_commands_report_client_and_server_versions() { let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); assert_eq!(server_json["status"], "running"); assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 10); + assert_eq!(server_json["protocol"], 11); assert_eq!(server_json["compatible"], true); let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 10); + assert_eq!(client_json["protocol"], 11); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 6e3e52cd..b7504aca 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -274,8 +274,8 @@ fn client_connects_and_receives_frame() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10, "server should report protocol version 10"); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11, "server should report protocol version 11"); assert!( error.is_none(), "handshake should not have error: {:?}", @@ -342,8 +342,8 @@ fn client_sees_headless_startup_config_diagnostic() { let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); stream @@ -391,8 +391,8 @@ fn client_input_forwarded_to_pane() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Send an Input message containing "echo hello\n". @@ -445,8 +445,8 @@ fn client_resize_sends_message() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain the initial frame(s). @@ -504,8 +504,8 @@ fn server_shutdown_sends_message_to_client() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Send SIGINT so the server takes the graceful shutdown path and @@ -736,8 +736,8 @@ fn client_receives_frame_after_pane_output() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); read_next_frame_payload(&mut stream, Duration::from_secs(10)) @@ -783,8 +783,8 @@ fn navigate_mode_keybind_dispatch_in_server() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -901,8 +901,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). @@ -1000,8 +1000,8 @@ fn client_receives_notify_on_agent_state_change() { // Connect as a client and perform handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 37adda43..008358d0 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -686,7 +686,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Local attach (client A). let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 10, 100, 30); + client_handshake(&mut client_a, 11, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); // Use herdr: create a workspace and write output into its pane. @@ -723,7 +723,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Reattach from another terminal/session (client B). let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 10, 80, 24); + client_handshake(&mut client_b, 11, 80, 24); assert!( wait_for_frame(&mut client_b, Duration::from_secs(5)), "reattached client should receive frame" @@ -779,7 +779,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 10, 100, 30); + client_handshake(&mut client_a, 11, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); let created = workspace_create(&api_socket, "agent-persist"); @@ -832,7 +832,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { // Reattach and ensure client-side state reflects the persisted working status. let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 10, 80, 24); + client_handshake(&mut client_b, 11, 80, 24); let saw_working_on_client = wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { frame_contains_text(frame, "working") @@ -877,7 +877,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client = UnixStream::connect(&client_socket).expect("client should connect"); - client_handshake(&mut client, 10, 100, 30); + client_handshake(&mut client, 11, 100, 30); assert!(wait_for_frame(&mut client, Duration::from_secs(2))); drain_server_messages(&mut client, Duration::from_millis(300)); @@ -940,9 +940,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 10, 110, 30); + client_handshake(&mut client_a, 11, 110, 30); let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 10, 100, 30); + client_handshake(&mut client_b, 11, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); assert!(wait_for_frame(&mut client_b, Duration::from_secs(2))); @@ -1111,7 +1111,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() { let mut reconnect_client = UnixStream::connect(&client_socket).expect("new client should connect after restart"); - client_handshake(&mut reconnect_client, 10, 80, 24); + client_handshake(&mut reconnect_client, 11, 80, 24); assert!( wait_for_frame(&mut reconnect_client, Duration::from_secs(5)), "new client should receive frame after restart" diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs index d486fb82..1e90d94b 100644 --- a/tests/detach_reattach.rs +++ b/tests/detach_reattach.rs @@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() { // --- Client A --- let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = - client_handshake(&mut stream_a, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream_a, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = - client_handshake(&mut stream_b, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream_b, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!( error.is_none(), "reattach handshake should succeed: {:?}", @@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() { // Reattach — verify we can connect and receive a frame. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = - client_handshake(&mut stream_b, 10, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Verify the reattached client receives a frame. @@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() { // Reattach — verify we can connect and handshake again. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = - client_handshake(&mut stream_b, 10, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "reattach should succeed: {:?}", error); cleanup_spawned_herdr(spawned, base); @@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() { let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); let (version, error) = - client_handshake(&mut stream, 10, 120, 40).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream, 11, 120, 40).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); drain_messages(&mut stream); @@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() { // Connect and handshake client A. let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = - client_handshake(&mut stream_a, 10, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream_a, 11, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Detach client A immediately. @@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = - client_handshake(&mut stream_b, 10, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 10); + client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 11); assert!(error.is_none(), "{:?}", error); // Client B should receive a frame with the current state. diff --git a/tests/multi_client.rs b/tests/multi_client.rs index cf507c44..ca525e69 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -550,7 +550,7 @@ fn client_handshake( fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream { let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket"); - client_handshake(&mut stream, 10, cols, rows).expect("handshake should succeed"); + client_handshake(&mut stream, 11, cols, rows).expect("handshake should succeed"); stream } diff --git a/tests/server_headless.rs b/tests/server_headless.rs index a81d8302..332398e0 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -594,9 +594,9 @@ fn client_handshake_succeeds() { // Send Hello with the current protocol version, 80 cols, 24 rows. let (version, error) = - client_handshake(&mut stream, 10, 80, 24).expect("handshake should succeed"); + client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 10, "server should report protocol version 10"); + assert_eq!(version, 11, "server should report protocol version 11"); assert!( error.is_none(), "handshake should not have an error: {:?}", @@ -625,7 +625,7 @@ fn client_handshake_rejects_incompatible_version() { let (version, error) = client_handshake(&mut stream, 0, 80, 24) .expect("should read Welcome response even on rejection"); - assert_eq!(version, 10, "server should report its version 10"); + assert_eq!(version, 11, "server should report its version 11"); assert!( error.is_some(), "version 0 should be rejected with an error" @@ -650,10 +650,10 @@ fn client_handshake_clamps_small_terminal_size() { // Send Hello with 0x0 terminal size — should be clamped. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = client_handshake(&mut stream, 10, 0, 0) + let (version, error) = client_handshake(&mut stream, 11, 0, 0) .expect("handshake with 0x0 should succeed (server clamps)"); - assert_eq!(version, 10); + assert_eq!(version, 11); assert!( error.is_none(), "0x0 size should be accepted (clamped): {:?}", @@ -713,9 +713,9 @@ fn no_hello_client_closed_within_five_seconds() { // Verify the server is still healthy — a proper client can still connect. let mut good_stream = UnixStream::connect(&client_socket).expect("should connect after no-hello client"); - let (version, error) = client_handshake(&mut good_stream, 10, 80, 24) + let (version, error) = client_handshake(&mut good_stream, 11, 80, 24) .expect("proper handshake should still work after no-hello client"); - assert_eq!(version, 10); + assert_eq!(version, 11); assert!(error.is_none()); // API should still work. |
