aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 23:05:30 +0300
committerGitHub <noreply@github.com>2026-09-13 23:05:30 +0300
commitf8e59221cbe5117f8f691a36affa374f5a986a9f (patch)
tree3b2792683eff251b061fbbad7343ecffafe91aab /src/client
parent59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff)
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/client')
-rw-r--r--src/client/shell.rs1
-rw-r--r--src/client/shell/actions.rs4
-rw-r--r--src/client/shell/composition.rs1
-rw-r--r--src/client/shell/input.rs2
-rw-r--r--src/client/shell/link_hover.rs352
-rw-r--r--src/client/shell/mouse.rs1
-rw-r--r--src/client/shell/state.rs7
-rw-r--r--src/client/shell/surface_patch.rs3
-rw-r--r--src/client/shell/tests/link_hover.rs410
-rw-r--r--src/client/shell/tests/mod.rs1
-rw-r--r--src/client/shell/worktrees.rs1
11 files changed, 783 insertions, 0 deletions
diff --git a/src/client/shell.rs b/src/client/shell.rs
index 4c2fc6cd..c6c7f2ed 100644
--- a/src/client/shell.rs
+++ b/src/client/shell.rs
@@ -20,6 +20,7 @@ mod global_menu;
mod graphics;
mod input;
mod input_source;
+mod link_hover;
mod mobile;
mod mouse;
mod notification_policy;
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs
index 9190f5e4..95a24a54 100644
--- a/src/client/shell/actions.rs
+++ b/src/client/shell/actions.rs
@@ -483,6 +483,9 @@ impl ClientShellState {
{
return (false, Vec::new());
}
+ if let PendingEndpointKind::PaneLinkResolve { target } = pending.kind {
+ return self.complete_link_hover(target, result);
+ }
if result.is_ok() {
let timeout_key = ClientEndpointNoticeKey {
boot_id: boot_id.to_owned(),
@@ -528,6 +531,7 @@ impl ClientShellState {
}
match pending.kind {
PendingEndpointKind::Generic => {}
+ PendingEndpointKind::PaneLinkResolve { .. } => unreachable!("handled above"),
PendingEndpointKind::ProductAnnouncementDismiss { version, id } => {
return match result {
Ok(_) => (false, Vec::new()),
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index 3266e54d..8e99059b 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -371,6 +371,7 @@ impl ClientShellState {
}
frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor);
}
+ self.render_link_hover(&mut frame);
if self.mode == ClientShellMode::Copy {
frame.cursor = None;
if let Some(copy_mode) = self.copy_mode.as_ref() {
diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs
index 8b5fbd83..7118819b 100644
--- a/src/client/shell/input.rs
+++ b/src/client/shell/input.rs
@@ -247,6 +247,7 @@ impl ClientShellState {
.push(ClientMessage::ClientShellFocus { focused: true });
}
RawInputEvent::OuterFocusLost => {
+ outcome.repaint |= self.clear_link_hover();
self.outer_focused = Some(false);
self.release_input_leases(&mut outcome);
outcome
@@ -301,6 +302,7 @@ impl ClientShellState {
key: crate::input::TerminalKey,
outcome: &mut ClientShellInput,
) {
+ outcome.repaint |= self.clear_link_hover();
if self.copy_operation_in_flight {
self.copy_input_queue.push_back(key);
return;
diff --git a/src/client/shell/link_hover.rs b/src/client/shell/link_hover.rs
new file mode 100644
index 00000000..f35bfbc2
--- /dev/null
+++ b/src/client/shell/link_hover.rs
@@ -0,0 +1,352 @@
+use super::*;
+use crate::api::schema::{PaneLinkActivateParams, PaneLinkRegion};
+use crate::protocol::SurfaceRect;
+use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(super) struct LinkHoverTarget {
+ pane_id: String,
+ inner_rect: Rect,
+ source_rect: SurfaceRect,
+ content_revision: u64,
+ offset_from_bottom: Option<u64>,
+ row: u16,
+ col: u16,
+}
+
+impl LinkHoverTarget {
+ fn same_content(&self, other: &Self) -> bool {
+ self.pane_id == other.pane_id
+ && self.inner_rect == other.inner_rect
+ && self.source_rect == other.source_rect
+ && self.content_revision == other.content_revision
+ && self.offset_from_bottom == other.offset_from_bottom
+ }
+}
+
+pub(super) struct LinkHover {
+ target: LinkHoverTarget,
+ pub(super) regions: Vec<PaneLinkRegion>,
+ resolved: bool,
+}
+
+impl ClientShellState {
+ pub(super) fn clear_link_hover(&mut self) -> bool {
+ self.link_hover
+ .take()
+ .is_some_and(|hover| !hover.regions.is_empty())
+ }
+
+ fn link_hover_allowed(&self) -> bool {
+ self.mode == ClientShellMode::Terminal
+ && self.overlay.is_none()
+ && self.popup_terminal_id.is_none()
+ && self.outer_focused != Some(false)
+ && self.chrome_drag.is_none()
+ && self.pane_mouse_gesture.is_none()
+ }
+
+ fn link_hover_target_current(&self, target: &LinkHoverTarget) -> bool {
+ self.link_hover_allowed()
+ && self.pane_surface.as_ref().is_some_and(|surface| {
+ self.snapshot.as_ref().is_some_and(|snapshot| {
+ surface.boot_id == snapshot.boot_id
+ && surface.projection_revision == snapshot.revision
+ }) && surface.panes.iter().any(|pane| {
+ pane.pane_id == target.pane_id
+ && pane.inner_rect == target.source_rect
+ && pane.content_revision == target.content_revision
+ && pane.scroll.map(|scroll| scroll.offset_from_bottom)
+ == target.offset_from_bottom
+ })
+ })
+ && self
+ .hits
+ .panes
+ .iter()
+ .any(|hit| hit.pane_id == target.pane_id && hit.inner_rect == target.inner_rect)
+ }
+
+ pub(super) fn invalidate_link_hover(&mut self) {
+ if self
+ .link_hover
+ .as_ref()
+ .is_some_and(|hover| !self.link_hover_target_current(&hover.target))
+ {
+ self.clear_link_hover();
+ }
+ }
+
+ pub(super) fn update_link_hover(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) {
+ if mouse.kind != MouseEventKind::Moved
+ || !mouse.modifiers.contains(KeyModifiers::CONTROL)
+ || !self.link_hover_allowed()
+ {
+ outcome.repaint |= self.clear_link_hover();
+ return;
+ }
+ let Some(hit) = self
+ .hits
+ .panes
+ .iter()
+ .find(|hit| contains(hit.inner_rect, (mouse.column, mouse.row)))
+ else {
+ outcome.repaint |= self.clear_link_hover();
+ return;
+ };
+ let Some(pane) = self.pane_surface.as_ref().and_then(|surface| {
+ surface
+ .panes
+ .iter()
+ .find(|pane| pane.pane_id == hit.pane_id)
+ }) else {
+ outcome.repaint |= self.clear_link_hover();
+ return;
+ };
+ let target = LinkHoverTarget {
+ pane_id: hit.pane_id.clone(),
+ inner_rect: hit.inner_rect,
+ source_rect: pane.inner_rect,
+ content_revision: pane.content_revision,
+ offset_from_bottom: pane.scroll.map(|scroll| scroll.offset_from_bottom),
+ row: mouse.row.saturating_sub(hit.inner_rect.y),
+ col: mouse.column.saturating_sub(hit.inner_rect.x),
+ };
+ if !self.link_hover_target_current(&target) || !target.content_revision.is_multiple_of(2) {
+ outcome.repaint |= self.clear_link_hover();
+ return;
+ }
+ if self.link_hover.as_ref().is_some_and(|hover| {
+ hover.target.same_content(&target)
+ && (hover.target == target
+ || hover
+ .regions
+ .iter()
+ .any(|region| region_contains(region, target.col, target.row)))
+ }) {
+ self.request_link_hover(outcome);
+ return;
+ }
+ outcome.repaint |= self.clear_link_hover();
+ let explicit = self.explicit_link_regions(&target);
+ let resolved = explicit.is_some();
+ let regions = explicit.unwrap_or_default();
+ outcome.repaint |= !regions.is_empty();
+ self.link_hover = Some(LinkHover {
+ target,
+ regions,
+ resolved,
+ });
+ self.request_link_hover(outcome);
+ }
+
+ fn request_link_hover(&mut self, outcome: &mut ClientShellInput) {
+ if self
+ .pending_requests
+ .values()
+ .any(|pending| matches!(pending.kind, PendingEndpointKind::PaneLinkResolve { .. }))
+ {
+ return;
+ }
+ let Some(hover) = self.link_hover.as_ref().filter(|hover| !hover.resolved) else {
+ return;
+ };
+ if !self.link_hover_target_current(&hover.target) {
+ return;
+ }
+ let target = hover.target.clone();
+ // Hover is optional and speculative: do not probe unadvertised methods or show notices.
+ if !self.endpoint_is_online(&self.active_endpoint_id)
+ || !self
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ .and_then(|endpoint| endpoint.methods.as_ref())
+ .is_some_and(|methods| methods.iter().any(|method| method == "pane.link.resolve"))
+ {
+ if let Some(hover) = self.link_hover.as_mut() {
+ hover.resolved = true;
+ }
+ return;
+ }
+ self.push_endpoint_method_with_kind(
+ crate::api::schema::Method::PaneLinkResolve(PaneLinkActivateParams {
+ pane_id: target.pane_id.clone(),
+ viewport_row: target.row,
+ col: target.col,
+ content_revision: Some(target.content_revision),
+ offset_from_bottom: target.offset_from_bottom,
+ }),
+ PendingEndpointKind::PaneLinkResolve { target },
+ outcome,
+ );
+ }
+
+ pub(super) fn complete_link_hover(
+ &mut self,
+ target: LinkHoverTarget,
+ result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>,
+ ) -> (bool, Vec<ClientShellAction>) {
+ let cancelled = result
+ .as_ref()
+ .err()
+ .is_some_and(|error| error.code.as_deref() == Some("endpoint_cancelled"));
+ let mut outcome = ClientShellInput::default();
+ if self.link_hover_target_current(&target)
+ && self
+ .link_hover
+ .as_ref()
+ .is_some_and(|hover| hover.target == target)
+ {
+ let regions = match result {
+ Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions })
+ if regions.len() <= usize::from(target.inner_rect.height)
+ && regions.iter().all(|region| {
+ region.row < target.inner_rect.height
+ && region.start_col <= region.end_col
+ && region.end_col < target.inner_rect.width
+ })
+ && regions
+ .iter()
+ .any(|region| region_contains(region, target.col, target.row)) =>
+ {
+ regions
+ }
+ _ => Vec::new(),
+ };
+ outcome.repaint = !regions.is_empty();
+ self.link_hover = Some(LinkHover {
+ target,
+ regions,
+ resolved: true,
+ });
+ }
+ if !cancelled {
+ self.request_link_hover(&mut outcome);
+ }
+ (outcome.repaint, outcome.actions)
+ }
+
+ fn explicit_link_regions(&self, target: &LinkHoverTarget) -> Option<Vec<PaneLinkRegion>> {
+ let surface = self.pane_surface.as_ref()?;
+ let rect = target.source_rect;
+ let cell = |col: u16, row: u16| {
+ let x = usize::from(rect.x) + usize::from(col);
+ let y = usize::from(rect.y) + usize::from(row);
+ if x >= usize::from(surface.frame.width) || y >= usize::from(surface.frame.height) {
+ return None;
+ }
+ surface
+ .frame
+ .cells
+ .get(y * usize::from(surface.frame.width) + x)
+ };
+ let hyperlink_at = |col: u16, row: u16| {
+ let current = cell(col, row)?;
+ current.hyperlink.or_else(|| {
+ // Rendered wide-cell spacers may not carry their own OSC 8 metadata.
+ if col == 0 || !current.symbol.trim().is_empty() {
+ return None;
+ }
+ let previous = cell(col - 1, row)?;
+ (previous.symbol.width() == 2)
+ .then_some(previous.hyperlink)
+ .flatten()
+ })
+ };
+ let hyperlink = hyperlink_at(target.col, target.row)?;
+ let uri = surface.frame.hyperlinks.get(hyperlink as usize)?;
+ if crate::app::actions::safe_web_url(uri).is_none() {
+ return Some(Vec::new());
+ }
+ let width = usize::from(rect.width);
+ if width == 0 {
+ return Some(Vec::new());
+ }
+ let clicked = usize::from(target.row) * width + usize::from(target.col);
+ let mut start = clicked;
+ let mut end = clicked;
+ let matches = |index: usize| {
+ hyperlink_at((index % width) as u16, (index / width) as u16) == Some(hyperlink)
+ };
+ // Frame IDs identify destinations, not OSC 8 runs. Adjacent cells with
+ // the same destination form one hover region, including across rows.
+ let mut remaining = 8191usize;
+ while start > 0 && matches(start - 1) {
+ if remaining == 0 {
+ return Some(Vec::new());
+ }
+ remaining -= 1;
+ start -= 1;
+ }
+ while end + 1 < width * usize::from(rect.height) && matches(end + 1) {
+ if remaining == 0 {
+ return Some(Vec::new());
+ }
+ remaining -= 1;
+ end += 1;
+ }
+ let regions = (start / width..=end / width)
+ .map(|row| PaneLinkRegion {
+ row: row as u16,
+ start_col: if row == start / width {
+ (start % width) as u16
+ } else {
+ 0
+ },
+ end_col: if row == end / width {
+ (end % width) as u16
+ } else {
+ rect.width - 1
+ },
+ })
+ .collect();
+ Some(regions)
+ }
+
+ pub(super) fn link_hover_blocks_patch(
+ &self,
+ patch: &crate::protocol::PaneSurfacePatch,
+ ) -> bool {
+ self.link_hover.as_ref().is_some_and(|hover| {
+ !hover.regions.is_empty()
+ && patch
+ .panes
+ .iter()
+ .any(|pane| pane.pane_id == hover.target.pane_id)
+ })
+ }
+
+ pub(super) fn render_link_hover(&self, frame: &mut FrameData) {
+ let Some(hover) = self
+ .link_hover
+ .as_ref()
+ .filter(|hover| self.link_hover_target_current(&hover.target))
+ else {
+ return;
+ };
+ let rect = hover.target.inner_rect;
+ for region in &hover.regions {
+ let row = rect.y.saturating_add(region.row);
+ if row >= frame.height {
+ continue;
+ }
+ for col in region.start_col..=region.end_col {
+ let col = rect.x.saturating_add(col);
+ if col >= frame.width {
+ continue;
+ }
+ if let Some(cell) = frame
+ .cells
+ .get_mut(usize::from(row) * usize::from(frame.width) + usize::from(col))
+ {
+ cell.modifier |= Modifier::UNDERLINED.bits();
+ }
+ }
+ }
+ }
+}
+
+fn region_contains(region: &PaneLinkRegion, col: u16, row: u16) -> bool {
+ region.row == row && col >= region.start_col && col <= region.end_col
+}
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs
index 7b4c0cb2..1ce14feb 100644
--- a/src/client/shell/mouse.rs
+++ b/src/client/shell/mouse.rs
@@ -652,6 +652,7 @@ impl ClientShellState {
}
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) {
+ self.update_link_hover(mouse, outcome);
let point = (mouse.column, mouse.row);
if self.mode == ClientShellMode::Navigate
&& self.workspace_preview_action_blocked()
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index eede0f29..8f2aec43 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -699,6 +699,9 @@ pub(super) enum PendingEndpointKind {
absolute_row: u32,
generation: u64,
},
+ PaneLinkResolve {
+ target: super::link_hover::LinkHoverTarget,
+ },
PaneLinkActivate {
pane_id: String,
inner_rect: Rect,
@@ -934,6 +937,7 @@ pub(crate) struct ClientShellState {
pub(super) overlay: Option<ClientShellOverlay>,
pub(super) previous_pane_id: Option<String>,
pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>,
+ pub(super) link_hover: Option<super::link_hover::LinkHover>,
pub(super) url_click_consumes_until_up: bool,
pub(super) replaying_url_click: bool,
pub(super) selection: Option<crate::selection::Selection<String>>,
@@ -1093,6 +1097,7 @@ impl ClientShellState {
overlay,
previous_pane_id: None,
pane_mouse_gesture: None,
+ link_hover: None,
url_click_consumes_until_up: false,
replaying_url_click: false,
selection: None,
@@ -1284,6 +1289,7 @@ impl ClientShellState {
.then_some(ClientShellOverlay::Onboarding);
self.previous_pane_id = None;
self.pane_mouse_gesture = None;
+ self.link_hover = None;
self.url_click_consumes_until_up = false;
self.replaying_url_click = false;
self.selection = None;
@@ -1811,6 +1817,7 @@ impl ClientShellState {
self.graphics
.set_scene(std::mem::take(&mut surface.graphics));
self.pane_surface = Some(surface);
+ self.invalidate_link_hover();
self.resume_mobile_switcher_if_ready();
self.reconcile_input_source();
}
diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs
index 784fbcd8..87f947fa 100644
--- a/src/client/shell/surface_patch.rs
+++ b/src/client/shell/surface_patch.rs
@@ -72,6 +72,8 @@ fn fast_path_blocker(
Some("client_surface_patch.fallback.notification")
} else if state.copy_feedback.is_some() {
Some("client_surface_patch.fallback.copy_feedback")
+ } else if state.link_hover_blocks_patch(patch) {
+ Some("client_surface_patch.fallback.link_hover")
} else if state.selection.is_some() {
Some("client_surface_patch.fallback.selection")
} else if state.copy_mode.is_some() {
@@ -240,6 +242,7 @@ impl ClientShellState {
}
}
}
+ self.invalidate_link_hover();
self.reconcile_input_source();
} else {
let mut next = current.clone();
diff --git a/src/client/shell/tests/link_hover.rs b/src/client/shell/tests/link_hover.rs
new file mode 100644
index 00000000..f29fdec2
--- /dev/null
+++ b/src/client/shell/tests/link_hover.rs
@@ -0,0 +1,410 @@
+use super::*;
+
+fn hover_state() -> ClientShellState {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()]));
+ state.set_pane_surface(surface());
+ state.compose(106, 20).unwrap();
+ state
+}
+
+fn hover_mouse(state: &ClientShellState, col: u16, row: u16) -> MouseEvent {
+ let pane = &state.hits.panes[0];
+ MouseEvent {
+ kind: MouseEventKind::Moved,
+ column: pane.inner_rect.x + col,
+ row: pane.inner_rect.y + row,
+ modifiers: KeyModifiers::CONTROL,
+ }
+}
+
+fn hover_request(outcome: &ClientShellInput) -> String {
+ let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
+ panic!("expected one side-effect-free link resolution");
+ };
+ assert!(matches!(
+ request.method,
+ crate::api::schema::Method::PaneLinkResolve(_)
+ ));
+ request.id.clone()
+}
+
+fn resolve_hover(state: &mut ClientShellState, id: &str) -> bool {
+ let (repaint, actions) = state.handle_endpoint_result(
+ "boot-1",
+ id,
+ Ok(crate::api::schema::ResponseResult::PaneLinkResolved {
+ regions: vec![
+ crate::api::schema::PaneLinkRegion {
+ row: 0,
+ start_col: 1,
+ end_col: 3,
+ },
+ crate::api::schema::PaneLinkRegion {
+ row: 1,
+ start_col: 0,
+ end_col: 2,
+ },
+ ],
+ }),
+ );
+ assert!(
+ actions.is_empty(),
+ "hover must never open a URL or activate a plugin"
+ );
+ repaint
+}
+
+#[test]
+fn ctrl_hover_underlines_wrapped_segments_and_caches_the_link() {
+ let mut state = hover_state();
+ let mouse = hover_mouse(&state, 1, 0);
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ assert!(resolve_hover(&mut state, &id));
+ let frame = state.compose(106, 20).unwrap();
+ let pane = &state.hits.panes[0];
+ let underlined = |col, row| {
+ frame.cells[usize::from(pane.inner_rect.y + row) * usize::from(frame.width)
+ + usize::from(pane.inner_rect.x + col)]
+ .modifier
+ & Modifier::UNDERLINED.bits()
+ != 0
+ };
+ assert!(!underlined(0, 0));
+ assert!(underlined(1, 0));
+ assert!(underlined(3, 0));
+ assert!(underlined(0, 1));
+ assert!(underlined(2, 1));
+ assert!(!underlined(3, 1));
+ let moved = hover_mouse(&state, 2, 1);
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(moved)]);
+ assert!(outcome.actions.is_empty());
+ assert!(!outcome.repaint);
+ assert!(state.selection.is_none());
+}
+
+#[test]
+fn ctrl_hover_pending_request_does_not_block_another_endpoint_with_the_same_boot_id() {
+ use crate::client::endpoint::{
+ ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
+ };
+ let mut state = hover_state();
+ let mouse = hover_mouse(&state, 1, 0);
+ let old_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ let profile = SavedSshEndpoint {
+ id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Other connection".into(),
+ target: "host".into(),
+ session: "default".into(),
+ enabled: true,
+ };
+ let endpoint = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&endpoint, Box::new(snapshot()));
+ assert!(state.activate_endpoint_projection(&endpoint));
+ assert!(
+ state.pending_requests.is_empty(),
+ "endpoint switches retire old requests"
+ );
+ state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()]));
+ state.set_pane_surface(surface());
+ state.compose(106, 20).unwrap();
+ let mouse = hover_mouse(&state, 1, 0);
+ let new_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ assert!(
+ !resolve_hover(&mut state, &old_id),
+ "old endpoint must not paint here"
+ );
+ assert!(resolve_hover(&mut state, &new_id));
+}
+
+#[test]
+fn ctrl_hover_coalesces_motion_and_ignores_old_replies() {
+ let mut state = hover_state();
+ let first = hover_mouse(&state, 1, 0);
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(first)]));
+ for col in [2, 3, 0] {
+ let mouse = hover_mouse(&state, col, 1);
+ assert!(state
+ .handle_raw_events(vec![RawInputEvent::Mouse(mouse)])
+ .actions
+ .is_empty());
+ }
+ let (repaint, actions) = state.handle_endpoint_result(
+ "boot-1",
+ &id,
+ Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions: vec![] }),
+ );
+ assert!(!repaint);
+ assert!(
+ matches!(
+ &actions[..],
+ [ClientShellAction::Endpoint { request, .. }]
+ if matches!(&request.method, crate::api::schema::Method::PaneLinkResolve(params)
+ if params.viewport_row == 1 && params.col == 0)
+ ),
+ "only the latest pointer position should be queried"
+ );
+ assert!(state.link_hover.as_ref().unwrap().regions.is_empty());
+}
+
+#[test]
+fn ctrl_hover_clears_on_release_content_change_and_focus_loss() {
+ for clear in 0..4 {
+ let mut state = hover_state();
+ let mouse = hover_mouse(&state, 1, 0);
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ assert!(resolve_hover(&mut state, &id));
+ match clear {
+ 0 => {
+ state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ modifiers: KeyModifiers::empty(),
+ ..mouse
+ })]);
+ }
+ 1 => {
+ let mut next = surface();
+ next.surface_revision += 1;
+ next.panes[0].content_revision += 2;
+ state.set_pane_surface(next);
+ }
+ 2 => {
+ state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]);
+ }
+ _ => {
+ state.handle_raw_events(vec![RawInputEvent::Key(
+ crate::input::TerminalKey::new(
+ KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
+ KeyModifiers::empty(),
+ )
+ .with_kind(crossterm::event::KeyEventKind::Release),
+ )]);
+ }
+ }
+ assert!(state.link_hover.is_none());
+ }
+}
+
+#[test]
+fn ctrl_hover_content_patch_removes_all_old_underlines() {
+ let mut state = hover_state();
+ let mouse = hover_mouse(&state, 1, 0);
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ assert!(resolve_hover(&mut state, &id));
+ state.compose(106, 20).unwrap();
+ let mut pane = surface().panes.remove(0);
+ pane.content_revision = 2;
+ let patch = crate::protocol::PaneSurfacePatch {
+ boot_id: "boot-1".into(),
+ projection_revision: 1,
+ base_surface_revision: 1,
+ surface_revision: 2,
+ rows: vec![crate::protocol::PaneSurfacePatchRow {
+ x: 0,
+ y: 0,
+ cells: vec![surface().frame.cells[0].clone()],
+ }],
+ panes: vec![pane],
+ cursor: None,
+ };
+ assert!(matches!(
+ state.apply_pane_surface_patch(patch),
+ ClientPaneSurfacePatchOutcome::Applied(None)
+ ));
+ assert!(state.link_hover.is_none());
+ let frame = state.compose(106, 20).unwrap();
+ let pane = &state.hits.panes[0];
+ for row in 0..2 {
+ for col in 0..4 {
+ let idx = usize::from(pane.inner_rect.y + row) * usize::from(frame.width)
+ + usize::from(pane.inner_rect.x + col);
+ assert_eq!(frame.cells[idx].modifier & Modifier::UNDERLINED.bits(), 0);
+ }
+ }
+}
+
+#[test]
+fn ctrl_hover_preserves_fast_patches_for_other_panes() {
+ let mut state = hover_state();
+ let mut next = surface();
+ next.surface_revision = 2;
+ next.frame.height = 4;
+ next.frame.cells.extend(next.frame.cells.clone());
+ let mut other = next.panes[0].clone();
+ other.pane_id = "pane_2".into();
+ other.rect.y = 2;
+ other.inner_rect.y = 2;
+ next.panes.push(other.clone());
+ state.set_pane_surface(next);
+ state.compose(106, 20).unwrap();
+ let mouse = hover_mouse(&state, 1, 0);
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ assert!(resolve_hover(&mut state, &id));
+ state.compose(106, 20).unwrap();
+ other.content_revision = 2;
+ let patch = crate::protocol::PaneSurfacePatch {
+ boot_id: "boot-1".into(),
+ projection_revision: 1,
+ base_surface_revision: 2,
+ surface_revision: 3,
+ rows: vec![crate::protocol::PaneSurfacePatchRow {
+ x: 0,
+ y: 2,
+ cells: vec![surface().frame.cells[0].clone()],
+ }],
+ panes: vec![other],
+ cursor: None,
+ };
+ assert!(matches!(
+ state.apply_pane_surface_patch(patch),
+ ClientPaneSurfacePatchOutcome::Applied(Some(_))
+ ));
+ assert!(!state.link_hover.as_ref().unwrap().regions.is_empty());
+}
+
+#[test]
+fn ctrl_hover_ignores_late_reply_after_pointer_leaves() {
+ let mut state = hover_state();
+ let mouse = hover_mouse(&state, 1, 0);
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ column: 0,
+ row: 0,
+ ..mouse
+ })]);
+ assert!(!resolve_hover(&mut state, &id));
+ assert!(state.link_hover.is_none());
+}
+
+#[test]
+fn ctrl_hover_is_silent_when_unsupported_or_rejected() {
+ let mut state = hover_state();
+ state.set_endpoint_methods(Some(vec![]));
+ let mouse = hover_mouse(&state, 1, 0);
+ assert!(state
+ .handle_raw_events(vec![RawInputEvent::Mouse(mouse)])
+ .actions
+ .is_empty());
+ assert!(state.visible_endpoint_notice.is_none());
+ state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()]));
+ state.clear_link_hover();
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ state.handle_endpoint_result(
+ "boot-1",
+ &id,
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_timeout".into()),
+ message: "timeout".into(),
+ }),
+ );
+ assert!(state.visible_endpoint_notice.is_none());
+}
+
+#[test]
+fn ctrl_hover_explicit_wide_label_includes_the_spacer_cell() {
+ let mut state = hover_state();
+ state.set_endpoint_methods(Some(vec![]));
+ let mut next = surface();
+ next.surface_revision += 1;
+ next.frame.hyperlinks.push("https://example.com/".into());
+ next.frame.cells[1].symbol = "界".into();
+ next.frame.cells[1].hyperlink = Some(0);
+ next.frame.cells[2].symbol = "".into();
+ state.set_pane_surface(next);
+ let mouse = hover_mouse(&state, 2, 0);
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]);
+ assert!(outcome.actions.is_empty());
+ assert!(outcome.repaint);
+ let regions = &state.link_hover.as_ref().unwrap().regions;
+ assert_eq!(regions.len(), 1);
+ assert_eq!((regions[0].start_col, regions[0].end_col), (1, 2));
+}
+
+#[test]
+#[ignore = "non-gating fixed-geometry hover composition profile"]
+fn ctrl_hover_render_scale_profile() {
+ for count in [1, 15] {
+ let mut state = hover_state();
+ let mut next = surface();
+ next.surface_revision += 1;
+ next.frame =
+ FrameData::from_ratatui_buffer(&Buffer::with_lines(vec!["x".repeat(120); 30]), None);
+ let template = next.panes[0].clone();
+ next.panes.clear();
+ for index in 0..count {
+ let mut pane = template.clone();
+ pane.pane_id = format!("pane_{index}");
+ pane.inner_rect = SurfaceRect {
+ x: if count == 1 { 0 } else { (index % 5) * 24 },
+ y: if count == 1 { 0 } else { (index / 5) * 10 },
+ width: if count == 1 { 120 } else { 24 },
+ height: if count == 1 { 30 } else { 10 },
+ };
+ pane.rect = pane.inner_rect;
+ next.panes.push(pane);
+ }
+ state.set_pane_surface(next);
+ state.compose(146, 32).unwrap();
+ for active in [false, true] {
+ if active {
+ let pane = state.hits.panes.last().unwrap();
+ let mouse = MouseEvent {
+ kind: MouseEventKind::Moved,
+ column: pane.inner_rect.x + 1,
+ row: pane.inner_rect.y,
+ modifiers: KeyModifiers::CONTROL,
+ };
+ let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
+ assert!(resolve_hover(&mut state, &id));
+ }
+ let mut samples = Vec::new();
+ for _ in 0..200 {
+ let started = std::time::Instant::now();
+ std::hint::black_box(state.compose(146, 32).unwrap());
+ samples.push(started.elapsed().as_micros());
+ }
+ samples.sort_unstable();
+ eprintln!(
+ "ctrl-hover panes={count} active={active} median_us={} p95_us={}",
+ samples[100], samples[190]
+ );
+ assert!(
+ state.pending_requests.is_empty(),
+ "composition must never resolve links"
+ );
+ }
+ }
+}
+
+#[test]
+fn ctrl_hover_groups_contiguous_same_destination_cells_without_server_query() {
+ let mut state = hover_state();
+ state.set_endpoint_methods(Some(vec![]));
+ let mut next = surface();
+ next.surface_revision += 1;
+ next.frame.hyperlinks.push("https://example.com/".into());
+ for idx in 1..7 {
+ next.frame.cells[idx].hyperlink = Some(0);
+ }
+ state.set_pane_surface(next);
+ let mouse = hover_mouse(&state, 1, 0);
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]);
+ assert!(outcome.actions.is_empty());
+ assert!(outcome.repaint);
+ assert_eq!(state.link_hover.as_ref().unwrap().regions.len(), 2);
+
+ let mut next = state.pane_surface.as_ref().unwrap().clone();
+ next.surface_revision += 1;
+ next.panes[0].content_revision += 2;
+ next.frame.cells[3].hyperlink = None;
+ state.set_pane_surface(next);
+ state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]);
+ let regions = &state.link_hover.as_ref().unwrap().regions;
+ assert_eq!(regions.len(), 1, "an unlinked gap must stop grouping");
+ assert_eq!(
+ (regions[0].row, regions[0].start_col, regions[0].end_col),
+ (0, 1, 2)
+ );
+}
diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs
index a308c1f9..6633494c 100644
--- a/src/client/shell/tests/mod.rs
+++ b/src/client/shell/tests/mod.rs
@@ -251,6 +251,7 @@ mod endpoints;
#[path = "input.rs"]
mod input_domain;
mod keybindings_settings;
+mod link_hover;
mod mobile;
mod mouse_selection;
mod popup_focus_projection;
diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs
index 31ddbeee..d4253672 100644
--- a/src/client/shell/worktrees.rs
+++ b/src/client/shell/worktrees.rs
@@ -557,6 +557,7 @@ impl ClientShellState {
| PendingEndpointKind::PaneScroll { .. }
| PendingEndpointKind::WordSelection { .. }
| PendingEndpointKind::PaneLinkActivate { .. }
+ | PendingEndpointKind::PaneLinkResolve { .. }
| PendingEndpointKind::CopyMotion { .. }
| PendingEndpointKind::CopySearch { .. },
Err(_),