fix: preserve osc8 hyperlinks in client rendering

fixes #73
This commit is contained in:
Ogulcan Celik 2026-05-07 21:41:13 +03:00
parent dcfe11a758
commit b0aa7b2cdd
14 changed files with 487 additions and 95 deletions

View File

@ -941,7 +941,7 @@ mod tests {
id: "req_1".into(),
result: ResponseResult::Pong {
version: "0.1.2".into(),
protocol: 2,
protocol: 3,
},
};

View File

@ -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<String> {
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<Option<String>> {
frame
.hyperlinks
.iter()
.map(|uri| sanitized_hyperlink_uri(uri))
.collect()
}
fn sanitized_cell_hyperlink_uri<'a>(
sanitized_hyperlinks: &'a [Option<String>],
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<String>,
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<String>) {
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<String>,
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<String>],
cell: &CellData,
prev_sanitized_hyperlinks: &[Option<String>],
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();

View File

@ -401,12 +401,16 @@ impl Terminal {
}
pub fn screen_graphemes(&self, x: u16, y: u32) -> Result<Vec<u32>, 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<Option<String>, 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<ffi::GhosttyGridRef, Error> {
let mut grid_ref = ffi::GhosttyGridRef {
size: mem::size_of::<ffi::GhosttyGridRef>(),
..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<Vec<u32>, 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<Option<String>, 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<bool, Error> {
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<CellStyle, Error> {
let mut style = ffi::GhosttyStyle {
size: mem::size_of::<ffi::GhosttyStyle>(),
@ -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();

View File

@ -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),

View File

@ -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<Vec<((u16, u16), String, String)>, 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<String, crate::ghostty::Error> {
let GhosttyPaneCore {
terminal,

View File

@ -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<CursorState> {
if app_state.mode != Mode::Terminal {
return None;
@ -1120,6 +1141,13 @@ impl HeadlessServer {
fn frame_server_message(msg: &ServerMessage) -> Result<Vec<u8>, 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);

View File

@ -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<u32>,
}
/// 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<CursorState>,
/// OSC 8 hyperlink URIs referenced by cells.
pub hyperlinks: Vec<String>,
}
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<CursorState>,
) -> Self {
Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[])
}
pub fn from_ratatui_buffer_with_hyperlinks(
buffer: &ratatui::buffer::Buffer,
cursor: Option<CursorState>,
hyperlinks: &[((u16, u16), String, String)],
) -> Self {
let area = buffer.area;
let width = area.width;
let height = area.height;
let mut hyperlink_uris = Vec::<String>::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());
}

View File

@ -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);
}

View File

@ -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!(

View File

@ -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<CellWire>,
width: u16,
height: u16,
cursor: Option<CursorWire>,
hyperlinks: Vec<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct CellWire {
symbol: String,
@ -173,6 +176,7 @@ struct CellWire {
bg: u32,
modifier: u16,
skip: bool,
hyperlink: Option<u32>,
}
#[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).

View File

@ -483,14 +483,17 @@ fn is_timeout(err: &io::Error) -> bool {
)
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct FrameWire {
cells: Vec<CellWire>,
width: u16,
height: u16,
cursor: Option<CursorWire>,
hyperlinks: Vec<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct CellWire {
symbol: String,
@ -498,6 +501,7 @@ struct CellWire {
bg: u32,
modifier: u16,
skip: bool,
hyperlink: Option<u32>,
}
#[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"

View File

@ -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.

View File

@ -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<CellWire>,
width: u16,
height: u16,
cursor: Option<CursorWire>,
hyperlinks: Vec<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct CellWire {
symbol: String,
@ -580,6 +583,7 @@ struct CellWire {
bg: u32,
modifier: u16,
skip: bool,
hyperlink: Option<u32>,
}
#[derive(Debug, Deserialize)]

View File

@ -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.