Make pinned transcript prompts clickable (#52778)

## What changed

Clicking a pinned prompt header with an unmodified left click now scrolls the transcript to the original prompt. The click also ends text selection, cancels search, and clears activity focus. Track the prompt by entry identity so navigation remains correct when history is prepended.

## Testing

Update the prompt-header snapshot test to navigate by clicking the header after prepending history and verify that the original prompt becomes visible.

GitOrigin-RevId: dea811693853e3740909ab675b1339f701e01e02
This commit is contained in:
Felipe Coury
2026-10-10 04:33:30 +00:00
committed by copyberry
parent 322bbf4d84
commit c3d3b142d1
4 changed files with 61 additions and 23 deletions
+7 -2
View File
@@ -101,6 +101,7 @@ pub(crate) struct TranscriptView {
live_key: Option<(u16, ActiveCellTranscriptKey)>,
live_continuation: bool,
area: Rect,
prompt_header_target: Option<(Rect, EntryKey)>,
suppressed_prompt_header: Option<prompt_header::SuppressedHeader>,
visible: Vec<VisibleRow>,
selection: Option<Selection>,
@@ -137,6 +138,7 @@ impl Default for TranscriptView {
live_key: None,
live_continuation: false,
area: Rect::default(),
prompt_header_target: None,
suppressed_prompt_header: None,
visible: Vec::new(),
selection: None,
@@ -173,6 +175,7 @@ impl TranscriptView {
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc<dyn HistoryCell>]) {
self.composer_tip = None;
self.prompt_header_target = None;
self.cache.begin_frame();
self.sync_history_tail(cells);
let current_cells = cells;
@@ -217,8 +220,10 @@ impl TranscriptView {
}
// Following may advance into the next turn after reserving the header row.
start = self.start(cells);
if let Some(header) = prompt_header::line(cells, start.0, area.width) {
header.render(Rect::new(area.x, area.y, area.width, /*height*/ 1), buf);
if let Some((header, key)) = prompt_header::line(cells, start.0, area.width) {
let header_area = Rect::new(area.x, area.y, area.width, /*height*/ 1);
header.render(header_area, buf);
self.prompt_header_target = Some((header_area, key));
} else {
body = area;
self.area = area;
+18 -4
View File
@@ -229,13 +229,29 @@ impl TranscriptView {
{
return Some(action);
}
let point = ScreenPosition::new(event.column, event.row);
if mode == MouseMode::Interactive
&& event.kind == MouseEventKind::Down(MouseButton::Left)
&& event.modifiers.is_empty()
&& let Some((area, key)) = self.prompt_header_target
&& area.contains(point)
{
self.last_click = None;
if let Some(index) = cells.iter().position(|cell| EntryKey::cell(cell) == key) {
self.end_selection(cells);
self.cancel_search();
self.clear_activity_focus();
self.jump_to_entry(cells, index);
}
return Some(ViewAction::Changed);
}
if mode == MouseMode::Interactive
&& event.kind == MouseEventKind::Down(MouseButton::Left)
&& event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER)
&& let Some((area, tip)) = &self.composer_tip
&& area.contains(ScreenPosition::new(event.column, event.row))
&& area.contains(point)
{
let column = usize::from(event.column - area.x);
return tip
@@ -245,9 +261,7 @@ impl TranscriptView {
.terminal_destination()
.map(ViewAction::OpenLink);
}
let inside = self
.area
.contains(ScreenPosition::new(event.column, event.row));
let inside = self.area.contains(point);
let dragging = self
.selection
.as_ref()
@@ -19,7 +19,7 @@ pub(super) fn line(
cells: &[Arc<dyn HistoryCell>],
first: usize,
width: u16,
) -> Option<Line<'static>> {
) -> Option<(Line<'static>, EntryKey)> {
// A prompt that is still visible supplies its own context. Only pin a preceding prompt.
if width < 16
|| cells
@@ -29,11 +29,16 @@ pub(super) fn line(
{
return None;
}
let prompt = cells[..first.min(cells.len())]
let (index, prompt) = cells[..first.min(cells.len())]
.iter()
.enumerate()
.rev()
.filter_map(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
.find(|prompt| prompt.has_visible_content())?;
.find_map(|(index, cell)| {
cell.as_any()
.downcast_ref::<UserHistoryCell>()
.filter(|prompt| prompt.has_visible_content())
.map(|prompt| (index, prompt))
})?;
// Bound work even for pasted multi-megabyte prompts; sanitize before painting controls.
let prefix: String = prompt.message.chars().take(/*n*/ 512).collect();
let message = sanitize_user_text(prefix.into())
@@ -45,17 +50,20 @@ pub(super) fn line(
} else {
message
};
Some(truncate_line_with_ellipsis_if_overflow(
Line::from(vec![
if prompt.spoken {
"› ".red().bold()
} else {
"› ".bold().dim()
},
message.into(),
])
.style(crate::style::history_prompt_style()),
usize::from(width.saturating_sub(1)),
Some((
truncate_line_with_ellipsis_if_overflow(
Line::from(vec![
if prompt.spoken {
"› ".red().bold()
} else {
"› ".bold().dim()
},
message.into(),
])
.style(crate::style::history_prompt_style()),
usize::from(width.saturating_sub(1)),
),
EntryKey::cell(&cells[index]),
))
}
@@ -58,7 +58,18 @@ fn prompt_header_tracks_reading_and_yields_to_visible_prompts_and_short_screens(
&mut view, &cells, /*width*/ 30, /*height*/ 5
))
));
view.jump_to_entry(&cells, /*index*/ 1);
assert!(matches!(
view.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
},
&cells,
),
Some(ViewAction::Changed)
));
snapshots.push(format!(
"Prompt visible\n{}",
text(&render(
@@ -80,7 +91,7 @@ fn prompt_header_tracks_reading_and_yields_to_visible_prompts_and_short_screens(
#[test]
fn header_sanitizes_controls_and_truncates_by_display_width() {
let cells = vec![user("\x1b[31m界 café\r\nnext question"), cell("answer")];
let line = line(&cells, /*first*/ 1, /*width*/ 18).unwrap();
let (line, _) = line(&cells, /*first*/ 1, /*width*/ 18).unwrap();
assert!(line.width() <= 18);
insta::assert_snapshot!(line.to_string());
}