Enable plain clicks on transcript links and style bare URLs (#46884)

## Why

Transcript links required a modified click to open, and bare URLs lacked the visual styling of explicit Markdown links.

## What changed

- Open links on release after an unmodified, stationary left click. Cancel activation after dragging, scrolling, or releasing elsewhere so text selection remains usable.
- Apply Markdown link styling to bare URLs while preserving surrounding prose, existing emphasis, Unicode, and table wrapping.

## Testing

Add gesture tests for bare and Markdown links at narrow and wide widths, drag and scroll cancellation, and existing modified-click behavior. Add styling tests and snapshots covering punctuation, emphasis, tabs, Unicode, and wrapped table URLs.

GitOrigin-RevId: 23d12c901932d7e3586d841e5a0ece00e422fccd
This commit is contained in:
Eric Traut
2026-09-20 20:47:14 +00:00
committed by copyberry
parent 2f34d236f5
commit 9fdff739ea
8 changed files with 338 additions and 4 deletions
+39
View File
@@ -1,4 +1,5 @@
//! Transcript gestures leave ordinary typing and composer editing with the existing input path.
//! Stationary link clicks open on release; dragging or scrolling keeps the gesture in selection.
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyCode;
@@ -206,6 +207,27 @@ impl TranscriptView {
self.extend_selection(event.column, event.row);
}
MouseEventKind::Up(MouseButton::Left) if dragging => {
// Open only a stationary click. Dragging back to the origin is still selection,
// and wheel scrolling clears the pointer even when it cannot move the viewport.
let link = self.selection.as_mut().and_then(|selection| {
(inside
&& !selection.moved
&& selection.pointer == Some(ScreenPosition::new(event.column, event.row))
&& event.modifiers.is_empty())
.then(|| selection.pressed_link.take())
.flatten()
});
let link = link.filter(|destination| {
self.visible
.get(usize::from(event.row - self.area.y))
.and_then(|visible| {
visible
.layout
.link_at(visible.row, event.column - self.area.x)
})
.as_ref()
== Some(destination)
});
if self
.selection
.as_ref()
@@ -217,6 +239,9 @@ impl TranscriptView {
if self.selected_text(cells).is_none() {
self.end_selection(cells);
}
if let Some(link) = link {
return Some(ViewAction::OpenLink(link));
}
}
_ => return None,
}
@@ -317,7 +342,21 @@ impl TranscriptView {
{
return None;
}
let link = (clicks == 1 && event.modifiers.is_empty())
.then(|| {
visible
.layout
.link_at(visible.row, event.column.saturating_sub(self.area.x))
})
.flatten();
self.begin_selection(cells, event.column, event.row, clicks);
if let Some(selection) = &mut self.selection {
selection.pressed_link = link;
}
Some(ViewAction::Changed)
}
}
#[cfg(test)]
#[path = "input_tests.rs"]
mod tests;
@@ -0,0 +1,173 @@
//! Link activation shares pointer gestures with selection without opening after a drag or scroll.
use super::*;
use crate::history_cell::AgentMarkdownCell;
use pretty_assertions::assert_eq;
fn transcript(markdown: &str, width: u16) -> (TranscriptView, Vec<Arc<dyn HistoryCell>>) {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(AgentMarkdownCell::new(
markdown.into(),
std::path::Path::new("/"),
))];
let mut view = TranscriptView::default();
let area = Rect::new(/*x*/ 2, /*y*/ 1, width, /*height*/ 8);
view.render(area, &mut Buffer::empty(area), &cells);
(view, cells)
}
fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn ordinary_click_opens_bare_and_markdown_links_on_release() {
for markdown in [
"https://example.com/docs",
"[docs](https://example.com/docs)",
] {
for width in [12, 60] {
let (mut view, cells) = transcript(markdown, width);
let down = mouse(
MouseEventKind::Down(MouseButton::Left),
/*column*/ 4,
/*row*/ 1,
);
assert!(matches!(
view.handle_mouse(down, &cells),
Some(ViewAction::Changed)
));
// Match the app's redraw before dispatching the next mouse event.
let area = view.area;
view.render(area, &mut Buffer::empty(area), &cells);
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
..down
};
let Some(ViewAction::OpenLink(url)) = view.handle_mouse(up, &cells) else {
panic!("click should open {markdown}");
};
assert_eq!(
(
url.as_str(),
view.selected_text(&cells),
view.is_following()
),
("https://example.com/docs", None, true)
);
assert!(view.handle_mouse(up, &cells).is_none());
}
}
}
#[test]
fn dragging_a_link_selects_text_and_never_opens_it() {
for return_to_origin in [false, true] {
let (mut view, cells) = transcript(
"[documentation](https://example.com/docs)",
/*width*/ 60,
);
view.handle_mouse(
mouse(
MouseEventKind::Down(MouseButton::Left),
/*column*/ 4,
/*row*/ 1,
),
&cells,
);
view.handle_mouse(
mouse(
MouseEventKind::Drag(MouseButton::Left),
/*column*/ 7,
/*row*/ 1,
),
&cells,
);
assert_eq!(view.selected_text(&cells).as_deref(), Some("doc"));
let column = if return_to_origin { 4 } else { 7 };
let action = view.handle_mouse(
mouse(
MouseEventKind::Up(MouseButton::Left),
column,
/*row*/ 1,
),
&cells,
);
assert!(matches!(action, Some(ViewAction::Changed)));
assert_eq!(
view.selected_text(&cells).as_deref(),
(!return_to_origin).then_some("doc")
);
}
}
#[test]
fn scrolling_or_releasing_elsewhere_cancels_link_activation() {
for interruption in [
mouse(MouseEventKind::ScrollUp, /*column*/ 4, /*row*/ 1),
mouse(
MouseEventKind::ScrollDown,
/*column*/ 4,
/*row*/ 1,
),
mouse(
MouseEventKind::Up(MouseButton::Left),
/*column*/ 5,
/*row*/ 1,
),
mouse(
MouseEventKind::Up(MouseButton::Left),
/*column*/ 0,
/*row*/ 0,
),
] {
let (mut view, cells) = transcript("[docs](https://example.com/docs)", /*width*/ 60);
view.handle_mouse(
mouse(
MouseEventKind::Down(MouseButton::Left),
/*column*/ 4,
/*row*/ 1,
),
&cells,
);
assert!(!matches!(
view.handle_mouse(interruption, &cells),
Some(ViewAction::OpenLink(_))
));
assert!(!matches!(
view.handle_mouse(
mouse(
MouseEventKind::Up(MouseButton::Left),
/*column*/ 4,
/*row*/ 1
),
&cells
),
Some(ViewAction::OpenLink(_))
));
}
}
#[test]
fn modified_clicks_still_open_immediately() {
for modifiers in [KeyModifiers::CONTROL, KeyModifiers::SUPER] {
let (mut view, cells) = transcript("[docs](https://example.com/docs)", /*width*/ 60);
let event = MouseEvent {
modifiers,
..mouse(
MouseEventKind::Down(MouseButton::Left),
/*column*/ 4,
/*row*/ 1,
)
};
let Some(ViewAction::OpenLink(url)) = view.handle_mouse(event, &cells) else {
panic!("modified click should open link");
};
assert_eq!(url, "https://example.com/docs");
assert!(view.selection.is_none());
}
}
@@ -16,6 +16,7 @@ pub(super) struct Selection {
pub(super) moved: bool,
pub(super) resume_on_empty: bool,
pub(super) pointer: Option<ScreenPosition>,
pub(super) pressed_link: Option<String>,
origin: (Anchor, Anchor),
unit: SelectionUnit,
preferred_column: Option<u16>,
@@ -57,6 +58,7 @@ impl TranscriptView {
moved: false,
resume_on_empty: was_following,
pointer: Some(ScreenPosition::new(column, row)),
pressed_link: None,
});
if was_following {
self.hold_position();