From b0aa7b2cddc57a55a1c0285a1ea82b59ac9783bd Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 7 May 2026 21:41:13 +0300 Subject: [PATCH] fix: preserve osc8 hyperlinks in client rendering fixes #73 --- src/api/schema.rs | 2 +- src/client/blit.rs | 188 ++++++++++++++++++++++++++++++++++++++- src/ghostty/mod.rs | 108 ++++++++++++++++------ src/pane.rs | 4 + src/pane/terminal.rs | 43 +++++++++ src/server/headless.rs | 40 ++++++++- src/server/protocol.rs | 65 +++++++++++++- tests/api_ping.rs | 2 +- tests/cli_wrapper.rs | 6 +- tests/client_mode.rs | 40 +++++---- tests/cross_area.rs | 20 +++-- tests/detach_reattach.rs | 44 ++++----- tests/multi_client.rs | 6 +- tests/server_headless.rs | 14 +-- 14 files changed, 487 insertions(+), 95 deletions(-) diff --git a/src/api/schema.rs b/src/api/schema.rs index d3fd4682..c9467d5a 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -941,7 +941,7 @@ mod tests { id: "req_1".into(), result: ResponseResult::Pong { version: "0.1.2".into(), - protocol: 2, + protocol: 3, }, }; diff --git a/src/client/blit.rs b/src/client/blit.rs index 1a4fcd81..3d562563 100644 --- a/src/client/blit.rs +++ b/src/client/blit.rs @@ -178,7 +178,11 @@ fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String { /// Checks if two cells are visually identical. #[cfg(test)] fn cells_equal(a: &CellData, b: &CellData) -> bool { - a.symbol == b.symbol && a.fg == b.fg && a.bg == b.bg && a.modifier == b.modifier + a.symbol == b.symbol + && a.fg == b.fg + && a.bg == b.bg + && a.modifier == b.modifier + && a.hyperlink == b.hyperlink // Skip flag is only for ratatui internal use, not visual. } @@ -226,6 +230,11 @@ fn blit_frame_to_with_cursor_memory( // on terminals that render the hardware cursor at intermediate CUP positions. let _ = writer.write_all(b"\x1b[?25l"); + // Start each frame from a known OSC 8 state. If a previous write was + // interrupted or the outer terminal had an active hyperlink, unlinked cells + // must not inherit it. + let _ = writer.write_all(b"\x1b]8;;\x1b\\"); + if full_redraw { // Clear the screen and write all cells. let _ = writer.write_all(b"\x1b[2J\x1b[H"); @@ -338,6 +347,7 @@ fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorStat } fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { + let mut active_hyperlink = None; for row in 0..frame.height { let mut to_skip = 0usize; for col in 0..frame.width { @@ -360,17 +370,87 @@ fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { let sgr = build_sgr(cell.fg, cell.bg, cell.modifier); let _ = writer.write_all(sgr.as_bytes()); + write_hyperlink_if_changed( + writer, + &mut active_hyperlink, + cell_hyperlink_uri(frame, cell), + ); + // Write the symbol. let _ = writer.write_all(cell.symbol.as_bytes()); to_skip = cell_width(cell).saturating_sub(1); } } + close_hyperlink(writer, &mut active_hyperlink); + // Reset style at the end. let _ = writer.write_all(b"\x1b[0m"); } -fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last_sgr: &mut String) { +fn cell_hyperlink_uri<'a>(frame: &'a FrameData, cell: &CellData) -> Option<&'a str> { + let index = cell.hyperlink? as usize; + frame.hyperlinks.get(index).map(String::as_str) +} + +fn sanitized_hyperlink_uri(uri: &str) -> Option { + let sanitized: String = uri + .chars() + .filter(|ch| *ch != '\x1b' && *ch != '\x07' && !ch.is_control()) + .collect(); + (!sanitized.is_empty()).then_some(sanitized) +} + +fn sanitized_frame_hyperlinks(frame: &FrameData) -> Vec> { + frame + .hyperlinks + .iter() + .map(|uri| sanitized_hyperlink_uri(uri)) + .collect() +} + +fn sanitized_cell_hyperlink_uri<'a>( + sanitized_hyperlinks: &'a [Option], + cell: &CellData, +) -> Option<&'a str> { + let index = cell.hyperlink? as usize; + sanitized_hyperlinks.get(index)?.as_deref() +} + +fn write_hyperlink_if_changed( + writer: &mut impl Write, + active: &mut Option, + requested: Option<&str>, +) { + let requested = requested.and_then(sanitized_hyperlink_uri); + if active.as_deref() == requested.as_deref() { + return; + } + + if active.is_some() { + let _ = writer.write_all(b"\x1b]8;;\x1b\\"); + } + *active = requested; + if let Some(uri) = active.as_deref() { + let _ = write!(writer, "\x1b]8;;{uri}\x1b\\"); + } +} + +fn close_hyperlink(writer: &mut impl Write, active: &mut Option) { + if active.take().is_some() { + let _ = writer.write_all(b"\x1b]8;;\x1b\\"); + } +} + +fn write_cell( + writer: &mut impl Write, + row: u16, + col: u16, + cell: &CellData, + last_sgr: &mut String, + active_hyperlink: &mut Option, + frame: &FrameData, +) { if cell.skip { return; } @@ -383,12 +463,31 @@ fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last *last_sgr = sgr; } + write_hyperlink_if_changed(writer, active_hyperlink, cell_hyperlink_uri(frame, cell)); let _ = writer.write_all(cell.symbol.as_bytes()); } /// Writes only the cells that changed between the previous and current frame. +fn cells_visually_equal( + sanitized_hyperlinks: &[Option], + cell: &CellData, + prev_sanitized_hyperlinks: &[Option], + prev_cell: &CellData, +) -> bool { + cell.symbol == prev_cell.symbol + && cell.fg == prev_cell.fg + && cell.bg == prev_cell.bg + && cell.modifier == prev_cell.modifier + && sanitized_cell_hyperlink_uri(sanitized_hyperlinks, cell) + == sanitized_cell_hyperlink_uri(prev_sanitized_hyperlinks, prev_cell) + // Skip flag is only for ratatui internal use, not visual. +} + fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) { let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes. + let mut active_hyperlink = None; + let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame); + let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev); for row in 0..frame.height { let mut invalidated = 0usize; @@ -399,8 +498,24 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD let cell = &frame.cells[idx]; let prev_cell = &prev.cells[idx]; - if !cell.skip && (cell != prev_cell || invalidated > 0) && to_skip == 0 { - write_cell(writer, row, col, cell, &mut last_sgr); + if !cell.skip + && (!cells_visually_equal( + &sanitized_hyperlinks, + cell, + &prev_sanitized_hyperlinks, + prev_cell, + ) || invalidated > 0) + && to_skip == 0 + { + write_cell( + writer, + row, + col, + cell, + &mut last_sgr, + &mut active_hyperlink, + frame, + ); } to_skip = cell_width(cell).saturating_sub(1); @@ -409,6 +524,8 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD } } + close_hyperlink(writer, &mut active_hyperlink); + // Reset style if we wrote anything. if !last_sgr.is_empty() { let _ = writer.write_all(b"\x1b[0m"); @@ -433,6 +550,7 @@ mod tests { bg, modifier, skip: false, + hyperlink: None, } } @@ -442,9 +560,16 @@ mod tests { width, height, cursor: None, + hyperlinks: Vec::new(), } } + fn linked_cell(symbol: &str, index: u32) -> CellData { + let mut cell = make_cell(symbol, 0, 0, 0); + cell.hyperlink = Some(index); + cell + } + #[test] fn color_to_sgr_fg_named_colors() { assert_eq!(color_to_sgr_fg(0x00_00_00_00), "39"); // Reset @@ -619,6 +744,7 @@ mod tests { y: 1, visible: true, }), + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -646,6 +772,7 @@ mod tests { y: 0, visible: true, }), + hyperlinks: Vec::new(), }; let hidden = FrameData { cells: vec![make_cell("B", 0, 0, 0); 9], @@ -656,6 +783,7 @@ mod tests { y: 1, visible: false, }), + hyperlinks: Vec::new(), }; let mut last_visible_cursor = None; let mut output = Vec::new(); @@ -680,6 +808,42 @@ mod tests { ); } + #[test] + fn blit_frame_emits_osc8_for_linked_cells() { + let mut frame = make_frame( + 3, + 1, + vec![ + linked_cell("L", 0), + linked_cell("i", 0), + make_cell("!", 0, 0, 0), + ], + ); + frame.hyperlinks.push("https://example.com".to_owned()); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L")); + assert!(output_str.contains('i')); + assert!(output_str.contains("\x1b]8;;\x1b\\")); + } + + #[test] + fn blit_frame_sanitizes_hyperlink_uris() { + let mut frame = make_frame(1, 1, vec![linked_cell("L", 0)]); + frame + .hyperlinks + .push("https://exa\x1b\x07mple.com".to_owned()); + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L")); + } + #[test] fn blit_frame_first_frame_produces_output() { let frame = make_frame( @@ -773,6 +937,7 @@ mod tests { y: 0, visible: true, }), + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -796,6 +961,7 @@ mod tests { y: 0, visible: false, }), + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -815,6 +981,7 @@ mod tests { width: 1, height: 1, cursor: None, + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -839,6 +1006,7 @@ mod tests { y: 0, visible: false, }), + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -858,6 +1026,7 @@ mod tests { y: 0, visible: true, }), + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -884,6 +1053,7 @@ mod tests { y: 0, visible: true, }), + hyperlinks: Vec::new(), }; let mut curr = prev.clone(); curr.cells[0] = make_cell("B", 0, 0, 0); @@ -920,12 +1090,14 @@ mod tests { y: 1, visible: true, }), + hyperlinks: Vec::new(), }; let hidden = FrameData { cells: vec![make_cell("B", 0, 0, 0); 9], width: 3, height: 3, cursor: None, + hyperlinks: Vec::new(), }; let mut last_visible_cursor = None; let mut output = Vec::new(); @@ -956,6 +1128,7 @@ mod tests { width: 3, height: 2, cursor: None, + hyperlinks: Vec::new(), }; let mut last_visible_cursor = None; let mut output = Vec::new(); @@ -980,12 +1153,14 @@ mod tests { y: 0, visible: true, }), + hyperlinks: Vec::new(), }; let curr = FrameData { cells: vec![make_cell("B", 0, 0, 0)], width: 1, height: 1, cursor: None, + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -1008,6 +1183,7 @@ mod tests { width: 3, height: 1, cursor: None, + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -1030,6 +1206,7 @@ mod tests { width: 3, height: 1, cursor: None, + hyperlinks: Vec::new(), }; let curr = FrameData { cells: vec![ @@ -1040,6 +1217,7 @@ mod tests { width: 3, height: 1, cursor: None, + hyperlinks: Vec::new(), }; let mut output = Vec::new(); @@ -1064,6 +1242,7 @@ mod tests { width: 3, height: 1, cursor: None, + hyperlinks: Vec::new(), }; let curr = FrameData { cells: vec![ @@ -1074,6 +1253,7 @@ mod tests { width: 3, height: 1, cursor: None, + hyperlinks: Vec::new(), }; let mut output = Vec::new(); diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 5648e606..60ca785d 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -401,12 +401,16 @@ impl Terminal { } pub fn screen_graphemes(&self, x: u16, y: u32) -> Result, Error> { - let point = ffi::GhosttyPoint { - tag: ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN, - value: ffi::GhosttyPointValue { - coordinate: ffi::GhosttyPointCoordinate { x, y }, - }, - }; + let grid_ref = self.grid_ref(ghostty_screen_point(x, y))?; + grid_ref_graphemes(&grid_ref) + } + + pub fn viewport_hyperlink_uri(&self, x: u16, y: u32) -> Result, Error> { + let grid_ref = self.grid_ref(ghostty_viewport_point(x, y))?; + grid_ref_hyperlink_uri(&grid_ref) + } + + fn grid_ref(&self, point: ffi::GhosttyPoint) -> Result { let mut grid_ref = ffi::GhosttyGridRef { size: mem::size_of::(), ..Default::default() @@ -414,28 +418,7 @@ impl Terminal { unsafe { ffi::ghostty_terminal_grid_ref(self.raw, point, &mut grid_ref).into_result()?; } - let mut required = 0usize; - let result = unsafe { - ffi::ghostty_grid_ref_graphemes(&grid_ref, ptr::null_mut(), 0, &mut required) - }; - if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE { - result.into_result()?; - } - let mut buffer = vec![0u32; required]; - if required == 0 { - return Ok(buffer); - } - unsafe { - ffi::ghostty_grid_ref_graphemes( - &grid_ref, - buffer.as_mut_ptr(), - buffer.len(), - &mut required, - ) - .into_result()?; - } - buffer.truncate(required); - Ok(buffer) + Ok(grid_ref) } pub fn read_text_viewport( @@ -620,6 +603,49 @@ fn ghostty_screen_point(x: u16, y: u32) -> ffi::GhosttyPoint { } } +fn grid_ref_graphemes(grid_ref: &ffi::GhosttyGridRef) -> Result, Error> { + let mut required = 0usize; + let result = + unsafe { ffi::ghostty_grid_ref_graphemes(grid_ref, ptr::null_mut(), 0, &mut required) }; + if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE { + result.into_result()?; + } + let mut buffer = vec![0u32; required]; + if required == 0 { + return Ok(buffer); + } + unsafe { + ffi::ghostty_grid_ref_graphemes(grid_ref, buffer.as_mut_ptr(), buffer.len(), &mut required) + .into_result()?; + } + buffer.truncate(required); + Ok(buffer) +} + +fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result, Error> { + let mut required = 0usize; + let result = + unsafe { ffi::ghostty_grid_ref_hyperlink_uri(grid_ref, ptr::null_mut(), 0, &mut required) }; + if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE { + result.into_result()?; + } + if required == 0 { + return Ok(None); + } + let mut buffer = vec![0u8; required]; + unsafe { + ffi::ghostty_grid_ref_hyperlink_uri( + grid_ref, + buffer.as_mut_ptr(), + buffer.len(), + &mut required, + ) + .into_result()?; + } + buffer.truncate(required); + Ok(Some(String::from_utf8_lossy(&buffer).into_owned())) +} + pub struct RenderState { raw: ffi::GhosttyRenderState_ptr, } @@ -1083,6 +1109,20 @@ impl<'a> RowCellIter<'a> { Ok(CellWide::from_raw(wide)) } + pub fn has_hyperlink(&self) -> Result { + let raw = self.raw_cell()?; + let mut has_hyperlink = false; + unsafe { + ffi::ghostty_cell_get( + raw, + ffi::GhosttyCellData_GHOSTTY_CELL_DATA_HAS_HYPERLINK, + (&mut has_hyperlink as *mut bool).cast(), + ) + .into_result()?; + } + Ok(has_hyperlink) + } + pub fn style(&self) -> Result { let mut style = ffi::GhosttyStyle { size: mem::size_of::(), @@ -1292,6 +1332,18 @@ mod tests { assert_eq!(text, "2EFGH3IJ"); } + #[test] + fn terminal_extracts_viewport_hyperlink_uri() { + let mut terminal = Terminal::new(20, 3, 0).unwrap(); + terminal.write(b"\x1b]8;;https://example.com\x1b\\Link\x1b]8;;\x1b\\"); + + assert_eq!( + terminal.viewport_hyperlink_uri(0, 0).unwrap().as_deref(), + Some("https://example.com") + ); + assert_eq!(terminal.viewport_hyperlink_uri(4, 0).unwrap(), None); + } + #[test] fn terminal_read_text_viewport_handles_wide_chars() { let mut terminal = Terminal::new(5, 3, 0).unwrap(); diff --git a/src/pane.rs b/src/pane.rs index 71f35ca9..da90420e 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -777,6 +777,10 @@ impl PaneRuntime { self.terminal.render(frame, area, show_cursor); } + pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { + self.terminal.visible_hyperlinks(area) + } + pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { let fallback = crate::input::KeyboardProtocol::from_kitty_flags( self.kitty_keyboard_flags.load(Ordering::Relaxed), diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index ba1dbea8..fcda5fa6 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -153,6 +153,10 @@ impl PaneTerminal { self.ghostty.render(frame, area, show_cursor); } + pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { + self.ghostty.visible_hyperlinks(area) + } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.ghostty.apply_host_terminal_theme(theme); } @@ -573,6 +577,14 @@ impl GhosttyPaneTerminal { .and_then(|mut core| ghostty_extract_selection(&mut core, selection).ok()) } + pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { + self.core + .lock() + .ok() + .and_then(|mut core| ghostty_visible_hyperlinks(&mut core, area).ok()) + .unwrap_or_default() + } + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { let Ok(mut core) = self.core.lock() else { return; @@ -664,6 +676,37 @@ impl GhosttyPaneTerminal { } } +fn ghostty_visible_hyperlinks( + core: &mut GhosttyPaneCore, + area: Rect, +) -> Result, crate::ghostty::Error> { + let GhosttyPaneCore { + terminal, + render_state, + .. + } = core; + render_state.update(terminal)?; + let mut row_iterator = crate::ghostty::RowIterator::new()?; + let mut row_cells = crate::ghostty::RowCells::new()?; + let mut rows = render_state.populate_row_iterator(&mut row_iterator)?; + let mut links = Vec::new(); + let mut y = 0u16; + while y < area.height && rows.next() { + let mut cells = rows.populate_cells(&mut row_cells)?; + let mut x = 0u16; + while x < area.width && cells.next() { + if cells.has_hyperlink()? { + if let Some(uri) = terminal.viewport_hyperlink_uri(x, y.into())? { + links.push(((area.x + x, area.y + y), ghostty_cell_symbol(&cells)?, uri)); + } + } + x += 1; + } + y += 1; + } + Ok(links) +} + fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result { let GhosttyPaneCore { terminal, diff --git a/src/server/headless.rs b/src/server/headless.rs index 11b9d606..5fc7a544 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -368,6 +368,27 @@ fn render_virtual( (buffer, cursor) } +fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), String, String)> { + let Some(ws_idx) = app_state.active else { + return Vec::new(); + }; + let Some(tab) = app_state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::active_tab) + else { + return Vec::new(); + }; + + let mut links = Vec::new(); + for info in &app_state.view.pane_infos { + if let Some(runtime) = tab.runtimes.get(&info.id) { + links.extend(runtime.visible_hyperlinks(info.inner_rect)); + } + } + links +} + fn focused_terminal_cursor(app_state: &AppState) -> Option { if app_state.mode != Mode::Terminal { return None; @@ -1120,6 +1141,13 @@ impl HeadlessServer { fn frame_server_message(msg: &ServerMessage) -> Result, protocol::FramingError> { let mut framed = Vec::new(); protocol::write_message(&mut framed, msg)?; + let payload_len = framed.len().saturating_sub(4); + if payload_len > MAX_FRAME_SIZE { + return Err(protocol::FramingError::Oversized { + claimed: payload_len, + max: MAX_FRAME_SIZE, + }); + } Ok(framed) } @@ -1568,7 +1596,9 @@ impl HeadlessServer { for (client_id, (cols, rows), is_foreground) in render_targets { let area = Rect::new(0, 0, cols, rows); let (buffer, cursor) = render_virtual(&mut self.app.state, area, is_foreground); - let frame = FrameData::from_ratatui_buffer(&buffer, cursor); + let hyperlinks = visible_hyperlinks(&self.app.state); + let frame = + FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks); let Some(client) = self.clients.get_mut(&client_id) else { continue; @@ -1584,6 +1614,14 @@ impl HeadlessServer { let message = ServerMessage::Frame(frame.clone()); let serialized = match Self::frame_server_message(&message) { Ok(framed) => framed, + Err(protocol::FramingError::Oversized { claimed, max }) => { + warn!( + client_id, + claimed, max, "skipping oversized frame for client" + ); + client.last_frame = Some(frame); + continue; + } Err(err) => { warn!(client_id, err = %err, "failed to serialize frame for client"); broken_clients.push(client_id); diff --git a/src/server/protocol.rs b/src/server/protocol.rs index ed0c6961..74e2d7f3 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -3,6 +3,7 @@ //! Defines the message types, framing, version negotiation, and safety //! constraints for the binary protocol over Unix domain sockets. +use std::collections::HashMap; use std::io::{self, Read, Write}; use serde::{Deserialize, Serialize}; @@ -12,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 2; +pub const PROTOCOL_VERSION: u32 = 3; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -74,6 +75,8 @@ pub struct CellData { pub modifier: u16, /// Whether this cell should be skipped during diff-based rendering. pub skip: bool, + /// Index into `FrameData::hyperlinks` for this cell's OSC 8 target, if any. + pub hyperlink: Option, } /// Cursor position within a rendered frame. @@ -98,6 +101,8 @@ pub struct FrameData { pub height: u16, /// Cursor state for this frame, if applicable. pub cursor: Option, + /// OSC 8 hyperlink URIs referenced by cells. + pub hyperlinks: Vec, } impl FrameData { @@ -106,24 +111,52 @@ impl FrameData { /// This converts ratatui's internal cell representation into the /// wire-protocol cell format. The conversion is lossless for all /// commonly used cell attributes. + #[cfg(test)] pub fn from_ratatui_buffer( buffer: &ratatui::buffer::Buffer, cursor: Option, + ) -> Self { + Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[]) + } + + pub fn from_ratatui_buffer_with_hyperlinks( + buffer: &ratatui::buffer::Buffer, + cursor: Option, + hyperlinks: &[((u16, u16), String, String)], ) -> Self { let area = buffer.area; let width = area.width; let height = area.height; + let mut hyperlink_uris = Vec::::new(); + let mut hyperlink_indices = HashMap::<&str, u32>::new(); + let mut hyperlink_by_position = HashMap::<(u16, u16), (&str, &str)>::new(); + for ((x, y), symbol, uri) in hyperlinks { + hyperlink_by_position.insert((*x, *y), (symbol.as_str(), uri.as_str())); + } let mut cells = Vec::with_capacity((width as usize) * (height as usize)); for row in 0..height { for col in 0..width { let cell = buffer.cell((col, row)).expect("cell within bounds"); + let hyperlink = hyperlink_by_position + .get(&(col, row)) + .and_then(|(symbol, uri)| { + if *symbol != cell.symbol() { + return None; + } + Some(*hyperlink_indices.entry(*uri).or_insert_with(|| { + let index = hyperlink_uris.len() as u32; + hyperlink_uris.push((*uri).to_owned()); + index + })) + }); cells.push(CellData { symbol: cell.symbol().to_owned(), fg: color_to_u32(cell.fg), bg: color_to_u32(cell.bg), modifier: modifier_to_u16(cell.modifier), skip: cell.skip, + hyperlink, }); } } @@ -133,6 +166,7 @@ impl FrameData { width, height, cursor, + hyperlinks: hyperlink_uris, } } @@ -555,6 +589,7 @@ mod tests { bg: color_to_u32(Color::Black), modifier: Modifier::BOLD.bits(), skip: false, + hyperlink: None, }, CellData { symbol: "i".into(), @@ -562,6 +597,7 @@ mod tests { bg: color_to_u32(Color::Reset), modifier: Modifier::ITALIC.bits(), skip: false, + hyperlink: None, }, CellData { symbol: "!".into(), @@ -569,6 +605,7 @@ mod tests { bg: color_to_u32(Color::Indexed(220)), modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(), skip: false, + hyperlink: Some(0), }, CellData { symbol: " ".into(), @@ -576,6 +613,7 @@ mod tests { bg: color_to_u32(Color::Reset), modifier: Modifier::empty().bits(), skip: true, + hyperlink: None, }, CellData { symbol: "→".into(), // multi-byte grapheme @@ -583,6 +621,7 @@ mod tests { bg: color_to_u32(Color::Blue), modifier: Modifier::REVERSED.bits(), skip: false, + hyperlink: None, }, CellData { symbol: "🦀".into(), // emoji, wide grapheme cluster @@ -590,6 +629,7 @@ mod tests { bg: color_to_u32(Color::Magenta), modifier: Modifier::empty().bits(), skip: false, + hyperlink: None, }, ], width: 3, @@ -599,12 +639,20 @@ mod tests { y: 0, visible: true, }), + hyperlinks: vec!["https://example.com".to_owned()], }; let msg = ServerMessage::Frame(frame.clone()); 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); + match decoded { + ServerMessage::Frame(frame) => { + assert_eq!(frame.cells[2].hyperlink, Some(0)); + assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]); + } + other => panic!("expected frame, got {other:?}"), + } } #[test] @@ -683,6 +731,7 @@ mod tests { bg: color_to_u32(Color::Indexed((i % 256) as u8)), modifier: ((i % 16) as u16), skip: i % 100 == 0, + hyperlink: None, }) .collect(); @@ -695,6 +744,7 @@ mod tests { y: 5, visible: true, }), + hyperlinks: Vec::new(), }; let msg = ServerMessage::Frame(frame); @@ -983,6 +1033,17 @@ mod tests { assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0))); assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220))); + let with_links = FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, + None, + &[((1, 0), "i".to_owned(), "https://example.com".to_owned())], + ); + assert_eq!(with_links.cells[1].hyperlink, Some(0)); + assert_eq!( + with_links.hyperlinks, + vec!["https://example.com".to_owned()] + ); + // Convert back to ratatui buffer and compare. let restored = frame.to_ratatui_buffer().expect("should reconstruct"); assert_eq!(restored.area, area); @@ -1004,12 +1065,14 @@ mod tests { bg: 0, modifier: 0, skip: false, + hyperlink: None, }; 5 ], // 5 cells but 3×2 = 6 expected width: 3, height: 2, cursor: None, + hyperlinks: Vec::new(), }; assert!(frame.to_ratatui_buffer().is_none()); } diff --git a/tests/api_ping.rs b/tests/api_ping.rs index a0e2d33b..b0411559 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -223,7 +223,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["id"], "req_1"); assert_eq!(value["result"]["type"], "pong"); assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(value["result"]["protocol"], 2); + assert_eq!(value["result"]["protocol"], 3); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 035264df..a68fcb6c 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -871,7 +871,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 2"), + full_stdout.contains(" protocol: 3"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -904,7 +904,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 2"), + server_stdout.contains("protocol: 3"), "stdout: {server_stdout}" ); @@ -916,7 +916,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 2"), + client_stdout.contains("protocol: 3"), "stdout: {client_stdout}" ); assert!( diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 958b48a3..f35ee301 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -158,14 +158,17 @@ fn ping_socket(socket_path: &PathBuf) -> String { response.trim().to_string() } +#[allow(dead_code)] #[derive(Debug, Deserialize)] struct FrameWire { cells: Vec, width: u16, height: u16, cursor: Option, + hyperlinks: Vec, } +#[allow(dead_code)] #[derive(Debug, Deserialize)] struct CellWire { symbol: String, @@ -173,6 +176,7 @@ struct CellWire { bg: u32, modifier: u16, skip: bool, + hyperlink: Option, } #[derive(Debug, Deserialize)] @@ -246,8 +250,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2, "server should report protocol version 2"); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3, "server should report protocol version 3"); assert!( error.is_none(), "handshake should not have error: {:?}", @@ -326,8 +330,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); stream @@ -375,8 +379,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); // Send an Input message containing "echo hello\n". @@ -429,8 +433,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); // Drain the initial frame(s). @@ -486,8 +490,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); // Send SIGINT so the server takes the graceful shutdown path and @@ -713,8 +717,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); // Read the initial frame (server renders immediately on client connect). @@ -767,8 +771,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -885,8 +889,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). @@ -983,8 +987,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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). diff --git a/tests/cross_area.rs b/tests/cross_area.rs index df2dced1..0740a7dd 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -483,14 +483,17 @@ fn is_timeout(err: &io::Error) -> bool { ) } +#[allow(dead_code)] #[derive(Debug, Deserialize)] struct FrameWire { cells: Vec, width: u16, height: u16, cursor: Option, + hyperlinks: Vec, } +#[allow(dead_code)] #[derive(Debug, Deserialize)] struct CellWire { symbol: String, @@ -498,6 +501,7 @@ struct CellWire { bg: u32, modifier: u16, skip: bool, + hyperlink: Option, } #[derive(Debug, Deserialize)] @@ -665,7 +669,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, 2, 100, 30); + client_handshake(&mut client_a, 3, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); // Use herdr: create a workspace and write output into its pane. @@ -702,7 +706,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, 2, 80, 24); + client_handshake(&mut client_b, 3, 80, 24); assert!( wait_for_frame(&mut client_b, Duration::from_secs(5)), "reattached client should receive frame" @@ -758,7 +762,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, 2, 100, 30); + client_handshake(&mut client_a, 3, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); let created = workspace_create(&api_socket, "agent-persist"); @@ -811,7 +815,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, 2, 80, 24); + client_handshake(&mut client_b, 3, 80, 24); let saw_working_on_client = wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { frame_contains_text(frame, "working") @@ -856,7 +860,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, 2, 100, 30); + client_handshake(&mut client, 3, 100, 30); assert!(wait_for_frame(&mut client, Duration::from_secs(2))); drain_server_messages(&mut client, Duration::from_millis(300)); @@ -919,9 +923,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, 2, 110, 30); + client_handshake(&mut client_a, 3, 110, 30); let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 2, 100, 30); + client_handshake(&mut client_b, 3, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); assert!(wait_for_frame(&mut client_b, Duration::from_secs(2))); @@ -1087,7 +1091,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, 2, 80, 24); + client_handshake(&mut reconnect_client, 3, 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 b71ee4cb..259cf350 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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream_a, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream_b, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream_b, 3, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream_b, 3, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 3); 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, 2, 120, 40).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream, 3, 120, 40).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream_a, 3, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 3); 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, 2, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 2); + client_handshake(&mut stream_b, 3, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 3); 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 062a0f60..f65538b4 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -542,7 +542,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, 2, cols, rows).expect("handshake should succeed"); + client_handshake(&mut stream, 3, cols, rows).expect("handshake should succeed"); stream } @@ -565,14 +565,17 @@ fn send_client_detach(stream: &mut UnixStream) { stream.flush().unwrap(); } +#[allow(dead_code)] #[derive(Debug, Deserialize)] struct FrameWire { cells: Vec, width: u16, height: u16, cursor: Option, + hyperlinks: Vec, } +#[allow(dead_code)] #[derive(Debug, Deserialize)] struct CellWire { symbol: String, @@ -580,6 +583,7 @@ struct CellWire { bg: u32, modifier: u16, skip: bool, + hyperlink: Option, } #[derive(Debug, Deserialize)] diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 19b78e95..687b886b 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -578,9 +578,9 @@ fn client_handshake_succeeds() { // Send Hello with version 2, 80 cols, 24 rows. let (version, error) = - client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed"); + client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 2, "server should report protocol version 2"); + assert_eq!(version, 3, "server should report protocol version 3"); assert!( error.is_none(), "handshake should not have an error: {:?}", @@ -609,7 +609,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, 2, "server should report its version 2"); + assert_eq!(version, 3, "server should report its version 3"); assert!( error.is_some(), "version 0 should be rejected with an error" @@ -634,10 +634,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, 2, 0, 0) + let (version, error) = client_handshake(&mut stream, 3, 0, 0) .expect("handshake with 0x0 should succeed (server clamps)"); - assert_eq!(version, 2); + assert_eq!(version, 3); assert!( error.is_none(), "0x0 size should be accepted (clamped): {:?}", @@ -697,9 +697,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, 2, 80, 24) + let (version, error) = client_handshake(&mut good_stream, 3, 80, 24) .expect("proper handshake should still work after no-hello client"); - assert_eq!(version, 2); + assert_eq!(version, 3); assert!(error.is_none()); // API should still work.