mirror of
https://github.com/openai/codex.git
synced 2026-05-23 12:34:25 +00:00
fix(tui): keep transcript search hits visible
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user