From e9c1ae401ef9602e77e1994778d24e6dfd15e556 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 19 May 2026 16:39:36 -0300 Subject: [PATCH] fix(tui): keep transcript search hits visible --- codex-rs/tui/src/pager_overlay.rs | 224 ++++++++++++++++++++++-------- 1 file changed, 169 insertions(+), 55 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index c5ff7cbcbb..2240dcbd97 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -56,6 +56,7 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; use ratatui::text::Text; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; @@ -539,32 +540,18 @@ impl Renderable for CellRenderable { } else { self.style }; - let p = Paragraph::new(Text::from( - self.cell - .transcript_lines_for_mode(area.width, self.render_mode), - )) - .style(style) - .wrap(Wrap { trim: false }); - p.render(area, buf); - + let mut lines = self + .cell + .transcript_lines_for_mode(area.width, self.render_mode); if let Some(active_match) = *self.active_match.borrow() && active_match.renderable_index == self.renderable_index - && active_match.end_col > active_match.start_col { - let y = area.y.saturating_add(active_match.line_index as u16); - if y < area.bottom() { - let width = active_match - .end_col - .saturating_sub(active_match.start_col) - .min(area.width); - if width > 0 { - buf.set_style( - Rect::new(area.x.saturating_add(active_match.start_col), y, width, 1), - accent_style().add_modifier(Modifier::REVERSED), - ); - } - } + highlight_match_in_lines(&mut lines, active_match); } + let p = Paragraph::new(Text::from(lines)) + .style(style) + .wrap(Wrap { trim: false }); + p.render(area, buf); } fn desired_height(&self, width: u16) -> u16 { @@ -581,32 +568,23 @@ struct TailLinesRenderable { impl Renderable for TailLinesRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(Text::from(self.lines.clone())) - .wrap(Wrap { trim: false }) - .render(area, buf); - + let mut lines = self.lines.clone(); if let Some(active_match) = *self.active_match.borrow() && active_match.renderable_index == self.renderable_index - && active_match.end_col > active_match.start_col { - let y = area.y.saturating_add(active_match.line_index as u16); - if y < area.bottom() { - let width = active_match - .end_col - .saturating_sub(active_match.start_col) - .min(area.width); - if width > 0 { - buf.set_style( - Rect::new(area.x.saturating_add(active_match.start_col), y, width, 1), - accent_style().add_modifier(Modifier::REVERSED), - ); - } - } + highlight_match_in_lines(&mut lines, active_match); } + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .render(area, buf); } - fn desired_height(&self, _width: u16) -> u16 { - u16::try_from(self.lines.len()).unwrap_or(u16::MAX) + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(self.lines.clone())) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) } } @@ -1091,19 +1069,20 @@ impl TranscriptOverlay { owner_user_prompt = Some(idx); } let top_padding = usize::from(!cell.is_stream_continuation() && idx > 0); - for (line_index, line) in cell - .transcript_lines_for_mode(width, self.render_mode) - .iter() - .enumerate() - { + let lines = cell.transcript_lines_for_mode(width, self.render_mode); + let mut scroll_line_index = top_padding; + for (line_index, line) in lines.iter().enumerate() { self.search.matches.extend(find_matches_in_line( idx, line_index, - top_padding.saturating_add(line_index), + scroll_line_index, line, &query, owner_user_prompt, + width, )); + scroll_line_index = + scroll_line_index.saturating_add(rendered_line_height(line, width)); } } @@ -1113,21 +1092,26 @@ impl TranscriptOverlay { .is_some_and(|key| !key.is_stream_continuation && !self.cells.is_empty()), ); let renderable_index = self.cells.len(); + let mut scroll_line_index = top_padding; for (line_index, line) in lines.iter().enumerate() { self.search.matches.extend(find_matches_in_line( renderable_index, line_index, - top_padding.saturating_add(line_index), + scroll_line_index, line, &query, owner_user_prompt, + width, )); + scroll_line_index = + scroll_line_index.saturating_add(rendered_line_height(line, width)); } } if !self.search.matches.is_empty() { self.search.active_match = Some(0); self.update_active_search_match(); + self.scroll_to_active_match(); } } @@ -1573,10 +1557,10 @@ impl TranscriptOverlay { } else { return self.view.handle_key_event(tui, key_event); } - if self.search.dirty { - if let Some(width) = self.last_content_width { - self.recompute_search_matches(width); - } + if self.search.dirty + && let Some(width) = self.last_content_width + { + self.recompute_search_matches(width); } tui.frame_requester() .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); @@ -1792,6 +1776,42 @@ fn line_plain_text(line: &Line<'_>) -> String { .collect() } +fn highlight_match_in_lines(lines: &mut [Line<'static>], active_match: RenderableMatch) { + if active_match.end_col <= active_match.start_col { + return; + } + let Some(line) = lines.get_mut(active_match.line_index) else { + return; + }; + + let mut col = 0u16; + let mut spans = Vec::new(); + for span in &line.spans { + for ch in span.content.chars() { + let char_width = u16::try_from(ch.width().unwrap_or(0)).unwrap_or(0); + let next_col = col.saturating_add(char_width); + let is_match = next_col > active_match.start_col && col < active_match.end_col; + let style = if is_match { + accent_style().add_modifier(Modifier::REVERSED) + } else { + span.style + }; + spans.push(Span::styled(ch.to_string(), style)); + col = next_col; + } + } + + let mut highlighted = line.clone(); + highlighted.spans = spans; + *line = highlighted; +} + +fn rendered_line_height(line: &Line<'_>, width: u16) -> usize { + Paragraph::new(Text::from(vec![line.clone()])) + .wrap(Wrap { trim: false }) + .line_count(width) +} + fn find_matches_in_line( renderable_index: usize, line_index: usize, @@ -1799,6 +1819,7 @@ fn find_matches_in_line( line: &Line<'_>, query: &str, owning_user_prompt_cell: Option, + width: u16, ) -> Vec { if query.is_empty() { return Vec::new(); @@ -1825,7 +1846,7 @@ fn find_matches_in_line( matches.push(SearchMatch { renderable_index, line_index, - scroll_line_index, + scroll_line_index: scroll_line_index.saturating_add(usize::from(start_col / width)), start_col, end_col, owning_user_prompt_cell, @@ -2208,6 +2229,96 @@ mod tests { ); } + #[test] + fn transcript_overlay_search_highlights_wrapped_later_match_text() { + let mut overlay = transcript_overlay(vec![user_cell( + "main first hit with enough padding words to push the second main hit onto a wrapped line", + )]); + overlay.activate_search(); + overlay.append_search_text("main"); + overlay.recompute_search_matches(/*width*/ 32); + overlay.select_next_match(); + + let area = Rect::new(0, 0, 32, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let highlighted = (area.y..area.bottom()) + .flat_map(|y| (area.x..area.right()).map(move |x| (x, y))) + .filter_map(|(x, y)| { + let cell = &buf[(x, y)]; + cell.style() + .add_modifier + .contains(Modifier::REVERSED) + .then(|| cell.symbol().to_string()) + }) + .collect::(); + + assert_eq!(highlighted, "main"); + } + + #[test] + fn transcript_overlay_search_highlights_match_after_viewport_wrapping() { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: vec![Line::from(format!( + "main first hit {}main second hit", + "padding ".repeat(16) + ))], + })]); + overlay.activate_search(); + overlay.append_search_text("main"); + overlay.recompute_search_matches(/*width*/ 24); + overlay.select_next_match(); + + let area = Rect::new(0, 0, 24, 8); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let highlighted = (area.y..area.bottom()) + .flat_map(|y| (area.x..area.right()).map(move |x| (x, y))) + .filter_map(|(x, y)| { + let cell = &buf[(x, y)]; + cell.style() + .add_modifier + .contains(Modifier::REVERSED) + .then(|| cell.symbol().to_string()) + }) + .collect::(); + + assert_eq!(highlighted, "main"); + } + + #[test] + fn transcript_overlay_search_scrolls_initial_match_into_view() { + let mut overlay = transcript_overlay(vec![ + user_cell("main first hit"), + Arc::new(TestCell { + lines: (0..24) + .map(|line| Line::from(format!("tail line {line}"))) + .collect(), + }), + ]); + overlay.activate_search(); + overlay.append_search_text("main"); + + let area = Rect::new(0, 0, 40, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let highlighted = (area.y..area.bottom()) + .flat_map(|y| (area.x..area.right()).map(move |x| (x, y))) + .filter_map(|(x, y)| { + let cell = &buf[(x, y)]; + cell.style() + .add_modifier + .contains(Modifier::REVERSED) + .then(|| cell.symbol().to_string()) + }) + .collect::(); + + assert_eq!(highlighted, "main"); + } + #[test] fn transcript_overlay_search_snapshot() { let mut overlay = transcript_overlay(vec![ @@ -2313,7 +2424,10 @@ mod tests { assert_eq!(overlay.selected_user_cell(), Some(3)); assert_eq!(overlay.header_title(), "Transcript ยท 1/2"); - assert_eq!(overlay.set_highlighted_user_prompt(2), None); + assert_eq!( + overlay.set_highlighted_user_prompt(/*nth_user_message*/ 2), + None + ); } #[test]