aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 21:21:58 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 21:30:04 +0300
commit420925d141c8805e610eb0d62d49d8b9b483d961 (patch)
treebbd1f778a038bf4387c704a0df921fa53e7af615
parent43c6ca3a5dbdcefc6c6dc29be9ce81d990baf39e (diff)
fix: support scrolling direct agent attaches
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md2
-rw-r--r--docs/next/website/src/content/docs/agents.mdx2
-rw-r--r--src/client/mod.rs221
-rw-r--r--src/protocol/wire.rs49
-rw-r--r--src/server/client_transport.rs31
-rw-r--r--src/server/headless.rs456
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/cli_wrapper.rs12
-rw-r--r--tests/client_mode.rs36
-rw-r--r--tests/cross_area.rs16
-rw-r--r--tests/detach_reattach.rs44
-rw-r--r--tests/multi_client.rs2
-rw-r--r--tests/server_headless.rs14
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.