From c3d3b142d10f4316b46e35aad7e5317e7e506cb7 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 10 Oct 2026 04:24:19 +0000 Subject: [PATCH] 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 --- codex-rs/tui/src/transcript_view.rs | 9 ++++- codex-rs/tui/src/transcript_view/input.rs | 22 +++++++++-- .../tui/src/transcript_view/prompt_header.rs | 38 +++++++++++-------- .../transcript_view/prompt_header_tests.rs | 15 +++++++- 4 files changed, 61 insertions(+), 23 deletions(-) diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index 31ebfec5ee..9d5b4da271 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -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, visible: Vec, selection: Option, @@ -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]) { 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; diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 8b1c759a25..e6050deec3 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -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() diff --git a/codex-rs/tui/src/transcript_view/prompt_header.rs b/codex-rs/tui/src/transcript_view/prompt_header.rs index b2d6953b97..b427277761 100644 --- a/codex-rs/tui/src/transcript_view/prompt_header.rs +++ b/codex-rs/tui/src/transcript_view/prompt_header.rs @@ -19,7 +19,7 @@ pub(super) fn line( cells: &[Arc], first: usize, width: u16, -) -> Option> { +) -> 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::()) - .find(|prompt| prompt.has_visible_content())?; + .find_map(|(index, cell)| { + cell.as_any() + .downcast_ref::() + .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]), )) } diff --git a/codex-rs/tui/src/transcript_view/prompt_header_tests.rs b/codex-rs/tui/src/transcript_view/prompt_header_tests.rs index 3915436844..66be2e2435 100644 --- a/codex-rs/tui/src/transcript_view/prompt_header_tests.rs +++ b/codex-rs/tui/src/transcript_view/prompt_header_tests.rs @@ -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()); }