aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-07 21:41:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-07 21:48:50 +0300
commitb0aa7b2cddc57a55a1c0285a1ea82b59ac9783bd (patch)
tree866e4bd3322e69c4916457cde420c7e4b74792ef /src
parentdcfe11a7588fcd42471159453810abc9b7720a57 (diff)
fix: preserve osc8 hyperlinks in client rendering
fixes #73
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/client/blit.rs188
-rw-r--r--src/ghostty/mod.rs108
-rw-r--r--src/pane.rs4
-rw-r--r--src/pane/terminal.rs43
-rw-r--r--src/server/headless.rs40
-rw-r--r--src/server/protocol.rs65
7 files changed, 415 insertions, 35 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<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();
@@ -681,6 +809,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(
2,
@@ -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<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>(),
@@ -1293,6 +1333,18 @@ mod tests {
}
#[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();
terminal.write("1A⚡".as_bytes());
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<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,
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<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);
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<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());
}