aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
4 files changed, 705 insertions, 52 deletions
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();