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()); }