diff options
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | CONFIGURATION.md | 2 | ||||
| -rw-r--r-- | src/app/actions.rs | 1 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 48 | ||||
| -rw-r--r-- | src/app/mod.rs | 29 | ||||
| -rw-r--r-- | src/app/runtime.rs | 6 | ||||
| -rw-r--r-- | src/app/state.rs | 18 | ||||
| -rw-r--r-- | src/client/mod.rs | 32 | ||||
| -rw-r--r-- | src/config/model.rs | 16 | ||||
| -rw-r--r-- | src/main.rs | 11 | ||||
| -rw-r--r-- | src/server/headless.rs | 42 | ||||
| -rw-r--r-- | src/server/protocol.rs | 15 | ||||
| -rw-r--r-- | src/ui.rs | 1 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 2 | ||||
| -rw-r--r-- | src/ui/tabs.rs | 35 | ||||
| -rw-r--r-- | tests/client_mode.rs | 42 |
16 files changed, 264 insertions, 38 deletions
@@ -97,4 +97,4 @@ The app update check and the in-app **What's New** flow both depend on that exac Do not edit `website/latest.json` during normal feature, fix, or test work. It describes the latest published release binaries, not the current unreleased source tree. The release workflow updates it after release assets are published. -When changing the server/client wire protocol, bump `src/server/protocol.rs::PROTOCOL_VERSION` and update all hardcoded protocol expectations and manual protocol fixtures in tests. Keep protocol test expectations intentionally explicit so compatibility changes are reviewed instead of silently following the constant. +When changing the server/client wire protocol, ensure `src/server/protocol.rs::PROTOCOL_VERSION` is bumped relative to the latest released tag, and update all hardcoded protocol expectations and manual protocol fixtures in tests. Multiple unreleased wire changes in the same release cycle do not need repeated bumps; Herdr supports tagged releases, not arbitrary `master` client/server compatibility. Keep protocol test expectations intentionally explicit so compatibility changes are reviewed instead of silently following the constant. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index c3c2faba..79d5f401 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -258,6 +258,7 @@ for `panel_bg`, you can also use `reset`, `default`, `none`, or `transparent` to ```toml [ui] sidebar_width = 26 +mouse_capture = true confirm_close = true show_agent_labels_on_pane_borders = false agent_panel_scope = "all" @@ -269,6 +270,7 @@ accent = "cyan" | option | default | description | |--------|---------|-------------| | `sidebar_width` | `26` | base sidebar width before auto-scaling | +| `mouse_capture` | `true` | capture mouse input for Herdr's mouse UI; set false to let the terminal handle normal clicks while still forwarding mouse to pane apps that request it | | `confirm_close` | `true` | ask before closing a workspace | | `show_agent_labels_on_pane_borders` | `false` | show detected/reported agent labels in split pane borders when no manual pane name is set | | `agent_panel_scope` | `all` | sidebar agent list scope: `current` or `all` | diff --git a/src/app/actions.rs b/src/app/actions.rs index 6263679c..9477ca15 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -441,6 +441,7 @@ impl AppState { area, self.tab_scroll, self.tab_scroll_follow_active, + self.mouse_capture, ); self.tab_scroll = layout.scroll; self.view.tab_hit_areas = layout.tab_hit_areas; diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 779af3ed..9b4055e5 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -25,6 +25,28 @@ use super::{ }; impl AppState { + pub(crate) fn handle_pane_mouse_only(&mut self, mouse: MouseEvent) { + if self.mode != Mode::Terminal { + return; + } + let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { + return; + }; + + match mouse.kind { + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + self.forward_pane_reported_wheel(&info, mouse); + } + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { + self.forward_pane_mouse_button(&info, mouse); + } + MouseEventKind::Moved => {} + } + } + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); @@ -1118,6 +1140,32 @@ impl AppState { true } + fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + return false; + }; + let Some(rt) = ws.runtimes.get(&info.id) else { + return false; + }; + if !rt + .input_state() + .is_some_and(crate::pane::InputState::mouse_reporting_enabled) + { + return false; + } + rt.scroll_reset(); + let column = mouse.column.saturating_sub(info.inner_rect.x); + let row = mouse.row.saturating_sub(info.inner_rect.y); + let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) else { + warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); + return true; + }; + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); + } + true + } + pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { return false; diff --git a/src/app/mod.rs b/src/app/mod.rs index 8ef9d8f6..b56e8fc9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -33,7 +33,10 @@ const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -use crossterm::terminal; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, terminal, +}; use ratatui::layout::Rect; use ratatui::DefaultTerminal; use tokio::sync::{mpsc, Notify}; @@ -364,6 +367,7 @@ impl App { sidebar_collapsed: false, sidebar_section_split, agent_panel_scope, + mouse_capture: config.ui.mouse_capture, confirm_close: config.ui.confirm_close, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, kitty_graphics_enabled: config.advanced.kitty_graphics, @@ -449,6 +453,7 @@ impl App { self.query_host_terminal_theme(); let mut needs_render = true; + let mut host_mouse_capture_active = self.state.mouse_capture; while !self.state.should_quit { if self.render_dirty.load(Ordering::Acquire) { @@ -497,6 +502,7 @@ impl App { let now = Instant::now(); self.sync_animation_timer(now); + self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; if needs_render && self.can_render_now(now) { self.render_dirty.swap(false, Ordering::AcqRel); @@ -577,6 +583,20 @@ impl App { Ok(()) } + fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { + let desired = self.state.should_capture_host_mouse(); + if desired == *active { + return Ok(()); + } + if desired { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } + *active = desired; + Ok(()) + } + pub(crate) fn dismiss_release_notes(&mut self) { let preview = self .state @@ -683,6 +703,7 @@ impl App { if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { self.state.sidebar_width = config.ui.sidebar_width; } + self.state.mouse_capture = config.ui.mouse_capture; self.state.confirm_close = config.ui.confirm_close; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; @@ -805,7 +826,11 @@ impl App { } } crate::raw_input::RawInputEvent::Mouse(mouse) => { - self.handle_mouse_event_headless(mouse); + if self.state.mouse_capture { + self.handle_mouse_event_headless(mouse); + } else { + self.state.handle_pane_mouse_only(mouse); + } } crate::raw_input::RawInputEvent::Paste(text) => { if self.state.mode == Mode::Terminal { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 3c67c306..6cdb6b43 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -87,7 +87,11 @@ impl App { true } crate::raw_input::RawInputEvent::Mouse(mouse) => { - self.handle_mouse(mouse); + if self.state.mouse_capture { + self.handle_mouse(mouse); + } else { + self.state.handle_pane_mouse_only(mouse); + } true } crate::raw_input::RawInputEvent::OuterFocusGained => { diff --git a/src/app/state.rs b/src/app/state.rs index c79fbc24..155851d7 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -709,6 +709,9 @@ pub struct AppState { /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_scope: AgentPanelScope, + /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only + /// captures mouse while the focused pane app requests mouse reporting. + pub mouse_capture: bool, pub confirm_close: bool, pub show_agent_labels_on_pane_borders: bool, pub kitty_graphics_enabled: bool, @@ -752,6 +755,20 @@ impl AppState { self.show_agent_labels_on_pane_borders } + pub fn focused_pane_requests_mouse_capture(&self) -> bool { + self.mode == Mode::Terminal + && self + .active + .and_then(|idx| self.workspaces.get(idx)) + .and_then(crate::workspace::Workspace::focused_runtime) + .and_then(crate::pane::PaneRuntime::input_state) + .is_some_and(crate::pane::InputState::mouse_reporting_enabled) + } + + pub fn should_capture_host_mouse(&self) -> bool { + self.mouse_capture || self.focused_pane_requests_mouse_capture() + } + pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { key_matches(key, self.prefix_code, self.prefix_mods) } @@ -877,6 +894,7 @@ impl AppState { sidebar_collapsed: false, sidebar_section_split: 0.5, agent_panel_scope: AgentPanelScope::AllWorkspaces, + mouse_capture: true, confirm_close: true, show_agent_labels_on_pane_borders: false, kitty_graphics_enabled: false, diff --git a/src/client/mod.rs b/src/client/mod.rs index 9679e95c..075eeb55 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -46,6 +46,8 @@ static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::ne struct ClientState { /// Stateful semantic-frame encoder used when the server sends FrameData. blit_encoder: blit::BlitEncoder, + /// Whether host mouse capture is currently active. + mouse_capture_active: bool, /// The terminal size we reported to the server in our last Hello/Resize. reported_size: (u16, u16), /// Client-local sound playback config, refreshed on server request. @@ -141,14 +143,18 @@ impl From<protocol::FramingError> for ClientError { // Terminal setup / restore // --------------------------------------------------------------------------- -/// Sets up the terminal for client mode (raw mode, mouse, keyboard enhancements). +/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). /// /// Returns a guard that restores the terminal when dropped. -fn setup_terminal() -> io::Result<TerminalGuard> { +fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { ratatui::init(); + if mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } execute!( io::stdout(), - EnableMouseCapture, EnableBracketedPaste, EnableFocusChange, PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) @@ -176,6 +182,14 @@ fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<() writer.flush() } +fn set_mouse_capture(enabled: bool) -> io::Result<()> { + if enabled { + execute!(io::stdout(), EnableMouseCapture) + } else { + execute!(io::stdout(), DisableMouseCapture) + } +} + fn restore_terminal_state(in_tmux: bool) { let _ = clear_received_kitty_graphics(&mut io::stdout()); @@ -337,7 +351,7 @@ pub fn run_client() -> io::Result<()> { // Now set up the terminal (raw mode, mouse, keyboard enhancements). // This must happen AFTER the handshake succeeds, so we don't leave // the terminal in raw mode if the server rejects us. - let _guard = setup_terminal().map_err(|err| { + let _guard = setup_terminal(false).map_err(|err| { eprintln!("herdr: failed to set up terminal: {err}"); err })?; @@ -372,6 +386,7 @@ pub fn run_client() -> io::Result<()> { should_quit, sound_config, kitty_graphics_enabled, + false, negotiated_encoding, ) .await @@ -416,10 +431,12 @@ async fn run_client_loop( should_quit: Arc<AtomicBool>, sound_config: crate::config::SoundConfig, kitty_graphics_enabled: bool, + mouse_capture_active: bool, negotiated_encoding: RenderEncoding, ) -> Result<(), ClientError> { let mut state = ClientState { blit_encoder: blit::BlitEncoder::new(), + mouse_capture_active, reported_size: (cols, rows), sound_config, kitty_graphics_enabled, @@ -530,6 +547,13 @@ async fn run_client_loop( ServerMessage::ReloadSoundConfig => { reload_local_sound_config(&mut state.sound_config); } + ServerMessage::MouseCapture { enabled } => { + let desired = enabled; + if desired != state.mouse_capture_active { + set_mouse_capture(desired).map_err(ClientError::ConnectionFailed)?; + state.mouse_capture_active = desired; + } + } ServerMessage::Welcome { .. } => { debug!("received unexpected Welcome in main loop"); } diff --git a/src/config/model.rs b/src/config/model.rs index ffc117ed..a576418b 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -129,6 +129,8 @@ pub struct KeysConfig { #[serde(default)] pub struct UiConfig { pub sidebar_width: u16, + /// Capture mouse input for Herdr's mouse UI. Default: true. + pub mouse_capture: bool, /// Ask for confirmation before closing a workspace. Default: true. pub confirm_close: bool, /// Show agent labels in split pane borders when no manual pane label is set. Default: false. @@ -195,6 +197,7 @@ impl Default for UiConfig { fn default() -> Self { Self { sidebar_width: 26, + mouse_capture: true, confirm_close: true, show_agent_labels_on_pane_borders: false, agent_panel_scope: AgentPanelScopeConfig::All, @@ -273,6 +276,19 @@ show_agent_labels_on_pane_borders = true } #[test] + fn mouse_capture_default_on_and_parse() { + let default_config = Config::default(); + assert!(default_config.ui.mouse_capture); + + let toml = r#" +[ui] +mouse_capture = false +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(!config.ui.mouse_capture); + } + + #[test] fn toast_config_parses() { let toml = r#" [ui.toast] diff --git a/src/main.rs b/src/main.rs index 7fcf624f..8a43746d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,6 +120,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Sidebar width (auto-scaled based on workspace names, this sets the default) # sidebar_width = 26 +# Capture mouse input for Herdr's mouse UI. +# Set false to let the terminal handle normal clicks, such as Cmd-clicking URLs. +# Pane apps like lazygit and btop can still receive mouse when they request it. +# mouse_capture = true + # Ask for confirmation before closing a workspace # confirm_close = true @@ -456,9 +461,13 @@ fn main() -> io::Result<()> { let result = rt.block_on(async { let mut terminal = ratatui::init(); + if config.ui.mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } execute!( io::stdout(), - EnableMouseCapture, EnableBracketedPaste, EnableFocusChange, PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) diff --git a/src/server/headless.rs b/src/server/headless.rs index f490ec45..3ff3e587 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -205,6 +205,8 @@ struct ClientConnection { graphics_cache: crate::kitty_graphics::HostGraphicsCache, /// Whether a render was skipped because the render channel was full. render_pending: bool, + /// Last host mouse capture mode sent to this client. + host_mouse_capture_active: Option<bool>, /// Channels for sending framed ServerMessage data to the client writer thread. writer: Option<ClientWriter>, } @@ -228,6 +230,7 @@ impl ClientConnection { render_state: ClientRenderState::new(render_encoding), graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), render_pending: false, + host_mouse_capture_active: None, writer, } } @@ -416,6 +419,7 @@ impl HeadlessServer { } self.drain_client_sound_config_reload_request(); + self.stream_host_mouse_capture_mode(); self.app.sync_headless_animation_timer(now); @@ -1423,6 +1427,44 @@ impl HeadlessServer { changed } + fn stream_host_mouse_capture_mode(&mut self) { + let enabled = self.app.state.should_capture_host_mouse(); + let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) + { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize mouse capture mode for clients"); + return; + } + }; + + let mut broken_clients: Vec<u64> = Vec::new(); + for (&client_id, client) in &mut self.clients { + if client.host_mouse_capture_active == Some(enabled) { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + if writer.control.send(serialized.clone()).is_err() { + debug!( + client_id, + "client writer channel closed during mouse capture update" + ); + broken_clients.push(client_id); + continue; + } + client.host_mouse_capture_active = Some(enabled); + } + + for client_id in broken_clients { + let foreground_changed = self.remove_client(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size(); + } + } + } + /// Renders the current state to client-sized virtual buffers and streams /// frames to all connected clients. fn render_and_stream(&mut self) { diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 8748c41c..e0a3c4a0 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -294,6 +294,12 @@ pub enum ServerMessage { /// Client-local sound config changed on disk; refresh it without reconnecting. ReloadSoundConfig, + + /// Whether the client should currently capture host mouse input. + MouseCapture { + /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting. + enabled: bool, + }, } // --------------------------------------------------------------------------- @@ -799,6 +805,15 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn server_mouse_capture_roundtrip() { + let msg = ServerMessage::MouseCapture { enabled: true }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + // ---- Framing ---- #[test] @@ -188,6 +188,7 @@ fn compute_view_internal( tab_bar_rect, app.tab_scroll, app.tab_scroll_follow_active, + app.mouse_capture, ) }) .unwrap_or_default(); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 8a104dba..f10c4241 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -716,7 +716,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); } - if list_bottom > area.y { + if app.mouse_capture && list_bottom > area.y { let new_rect = app.sidebar_new_button_rect(); frame.render_widget( Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))), diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index a6ce0e68..ce859432 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -98,11 +98,28 @@ pub(crate) fn compute_tab_bar_view( area: Rect, current_scroll: usize, follow_active: bool, + mouse_chrome: bool, ) -> TabBarView { if area.width == 0 || area.height == 0 { return TabBarView::default(); } + if !mouse_chrome { + let max_scroll = max_tab_scroll(ws, area); + let scroll = if follow_active { + centered_tab_scroll(ws, area).min(max_scroll) + } else { + current_scroll.min(max_scroll) + }; + return TabBarView { + scroll, + tab_hit_areas: layout_tab_hit_areas(ws, area, scroll), + scroll_left_hit_area: Rect::default(), + scroll_right_hit_area: Rect::default(), + new_tab_hit_area: Rect::default(), + }; + } + let area_right = area.x + area.width; let all_tabs_area = Rect::new( area.x, @@ -253,7 +270,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0 && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()); - if app.view.tab_scroll_left_hit_area.width > 0 { + if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { let style = if can_scroll_left { Style::default().fg(p.overlay1).bg(p.surface0) } else { @@ -268,7 +285,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { ); } - if app.view.tab_scroll_right_hit_area.width > 0 { + if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { let style = if can_scroll_right { Style::default().fg(p.overlay1).bg(p.surface0) } else { @@ -330,7 +347,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { } } - if app.view.new_tab_hit_area.width > 0 { + if app.mouse_capture && app.view.new_tab_hit_area.width > 0 { frame.render_widget( Paragraph::new(" + ").style(Style::default().fg(p.overlay1)), app.view.new_tab_hit_area, @@ -338,7 +355,11 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { } if first_visible_idx.is_some_and(|idx| idx > 0) { - let x = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width; + let x = if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { + app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width + } else { + area.x + }; if x < area.x + area.width { frame.buffer_mut()[(x, area.y)] .set_symbol("…") @@ -346,7 +367,11 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { } } if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) { - let x = app.view.tab_scroll_right_hit_area.x.saturating_sub(1); + let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { + app.view.tab_scroll_right_hit_area.x.saturating_sub(1) + } else { + area.x + area.width.saturating_sub(1) + }; if x >= area.x && x < area.x + area.width { frame.buffer_mut()[(x, area.y)] .set_symbol("…") diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 25e7d42c..150b8733 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -206,6 +206,21 @@ fn decode_frame_payload(payload: &[u8]) -> std::io::Result<FrameWire> { }) } +fn read_next_frame_payload(stream: &mut UnixStream, timeout: Duration) -> Result<Vec<u8>, String> { + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .map_err(|e| e.to_string())?; + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match read_server_message(stream) { + Ok((1, payload)) => return Ok(payload), + Ok(_) => continue, + Err(_) => continue, + } + } + Err("timed out waiting for Frame message".into()) +} + fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { if frame.cells.is_empty() { return false; @@ -259,20 +274,8 @@ fn client_connects_and_receives_frame() { error ); - // Read the next message from the server — should be a Frame (variant 1). - stream.set_nonblocking(false).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(10))) - .unwrap(); - - let (variant, _payload) = - read_server_message(&mut stream).expect("should receive a message from server"); - - // ServerMessage::Frame is variant 1. - assert_eq!( - variant, 1, - "expected Frame message (variant 1), got variant {variant}" - ); + read_next_frame_payload(&mut stream, Duration::from_secs(10)) + .expect("should receive a frame from server"); cleanup_spawned_herdr(spawned, base); } @@ -725,15 +728,8 @@ fn client_receives_frame_after_pane_output() { assert_eq!(version, 5); assert!(error.is_none(), "{:?}", error); - // Read the initial frame (server renders immediately on client connect). - stream.set_nonblocking(false).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - - let (variant, _payload) = - read_server_message(&mut stream).expect("should receive initial frame"); - assert_eq!(variant, 1, "initial message should be a Frame (variant 1)"); + read_next_frame_payload(&mut stream, Duration::from_secs(10)) + .expect("should receive initial frame"); // Send input to trigger a state change and re-render. let input_data = b"echo test-output\n".to_vec(); |
