fix(tui): keep transcript search hits visible

This commit is contained in:
Felipe Coury
2026-05-19 16:39:36 -03:00
parent c1f46e619c
commit e9c1ae401e

View File

@@ -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<usize>,
width: u16,
) -> Vec<SearchMatch> {
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::<String>();
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::<String>();
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::<String>();
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]