diff --git a/codex-rs/tui/src/conversation_viewport.rs b/codex-rs/tui/src/conversation_viewport.rs index a9ba690b08..0d1bead3ef 100644 --- a/codex-rs/tui/src/conversation_viewport.rs +++ b/codex-rs/tui/src/conversation_viewport.rs @@ -10,16 +10,22 @@ use std::sync::Arc; use ratatui::buffer::Buffer; use ratatui::crossterm::event::KeyEvent; +use ratatui::layout::Position; use ratatui::layout::Rect; +use ratatui::style::Modifier; use ratatui::text::Text; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use crate::chatwidget::ActiveCellRenderKey; +use crate::conversation_selection::CellSelectionProjection; +use crate::conversation_selection::ConversationSelection; +use crate::conversation_selection::SelectionPoint; use crate::history_cell::HistoryCell; use crate::history_cell::HistoryRenderMode; use crate::keymap::PagerKeymap; +use crate::pager_overlay::BottomFollowMode; use crate::pager_overlay::PagerContent; use crate::render::Insets; use crate::render::renderable::InsetRenderable; @@ -34,6 +40,23 @@ pub(crate) struct ConversationViewport { cells: Vec>, render_mode: HistoryRenderMode, live_tail_key: Option, + selection: ConversationSelection, + selection_scroll_offset: Option, + selection_projection_cache: Option, +} + +struct SelectionProjectionCache { + width: u16, + render_mode: HistoryRenderMode, + projections: Vec>, + computed: Vec, + layout: Vec, +} + +#[derive(Clone, Copy)] +struct SelectionCellLayout { + top: usize, + height: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -56,11 +79,31 @@ impl ConversationViewport { cells, render_mode, live_tail_key: None, + selection: ConversationSelection::default(), + selection_scroll_offset: None, + selection_projection_cache: None, } } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { - self.content.render(area, buf); + let bottom_follow = if self.selection.is_active() { + BottomFollowMode::Frozen + } else { + BottomFollowMode::Enabled + }; + self.content.render(area, buf, bottom_follow); + if self.selection.is_active() + && self + .selection_scroll_offset + .is_some_and(|offset| offset != self.content.scroll_offset()) + { + self.cancel_selection(); + } + if self.selection_projection_cache.is_some() { + self.ensure_selection_projections(area.width); + self.ensure_selected_projections(); + self.render_selection(area, buf); + } } pub(crate) fn handle_navigation_key(&mut self, area: Rect, key_event: KeyEvent) -> bool { @@ -75,7 +118,47 @@ impl ConversationViewport { self.content.handle_mouse_scroll(direction); } + pub(crate) fn begin_selection(&mut self, area: Rect, position: Position) -> bool { + let Some(point) = self.selection_point(area, position, /*clamp*/ false) else { + return false; + }; + self.selection.start(point); + self.selection_scroll_offset = Some(self.content.scroll_offset()); + true + } + + pub(crate) fn update_selection(&mut self, area: Rect, position: Position) -> bool { + let Some(point) = self.selection_point(area, position, /*clamp*/ true) else { + return false; + }; + self.selection.update(point) + } + + pub(crate) fn finish_selection(&mut self, area: Rect, position: Position) -> Option { + let point = self.selection_point(area, position, /*clamp*/ true); + self.selection.set_release_point(point); + self.ensure_selected_projections(); + let projections = self + .selection_projection_cache + .as_ref() + .map(|cache| cache.projections.as_slice()) + .unwrap_or_default(); + let selected = self.selection.finish(/*point*/ None, projections); + self.selection_scroll_offset = None; + selected + } + + pub(crate) fn cancel_selection(&mut self) { + self.selection.cancel(); + self.selection_scroll_offset = None; + } + + pub(crate) fn selection_is_active(&self) -> bool { + self.selection.is_active() + } + pub(crate) fn push_cell(&mut self, cell: Arc) { + self.invalidate_selection_projections(); let follow_bottom = self.content.is_following_bottom(); let had_prior_cells = !self.cells.is_empty(); let tail_renderable = self.take_live_tail_renderable(); @@ -105,6 +188,7 @@ impl ConversationViewport { } pub(crate) fn replace_cells(&mut self, cells: Vec>) { + self.invalidate_selection_projections(); let follow_bottom = self.content.is_following_bottom(); self.take_live_tail_renderable(); self.live_tail_key = None; @@ -120,6 +204,7 @@ impl ConversationViewport { if self.render_mode == render_mode { return; } + self.invalidate_selection_projections(); let follow_bottom = self.content.is_following_bottom(); self.take_live_tail_renderable(); self.live_tail_key = None; @@ -147,7 +232,7 @@ impl ConversationViewport { return; } - let follow_bottom = self.content.is_following_bottom(); + let follow_bottom = !self.selection.is_active() && self.content.is_following_bottom(); self.take_live_tail_renderable(); self.live_tail_key = next_key; if let Some(key) = next_key { @@ -235,6 +320,243 @@ impl ConversationViewport { fn take_live_tail_renderable(&mut self) -> Option> { (self.content.len() > self.cells.len()).then(|| self.content.pop())? } + + fn invalidate_selection_projections(&mut self) { + self.cancel_selection(); + self.selection_projection_cache = None; + } + + fn ensure_selection_projections(&mut self, width: u16) { + let cache_matches = self + .selection_projection_cache + .as_ref() + .is_some_and(|cache| { + cache.width == width + && cache.render_mode == self.render_mode + && cache.projections.len() == self.cells.len() + }); + if cache_matches { + return; + } + self.cancel_selection(); + let renderable_heights = self.content.renderable_heights(width); + let mut content_top = 0usize; + let layout = self + .cells + .iter() + .enumerate() + .map(|(index, cell)| { + let leading_spacing = usize::from(index > 0 && !cell.is_stream_continuation()); + let renderable_height = renderable_heights + .get(index) + .copied() + .map(usize::from) + .unwrap_or_else(|| { + leading_spacing.saturating_add(usize::from( + cell.desired_height_for_mode(width, self.render_mode), + )) + }); + let cell_layout = SelectionCellLayout { + top: content_top.saturating_add(leading_spacing), + height: renderable_height.saturating_sub(leading_spacing), + }; + content_top = content_top.saturating_add(renderable_height); + cell_layout + }) + .collect(); + self.selection_projection_cache = Some(SelectionProjectionCache { + width, + render_mode: self.render_mode, + projections: vec![None; self.cells.len()], + computed: vec![false; self.cells.len()], + layout, + }); + } + + fn ensure_cell_projection(&mut self, index: usize) { + let Some(cache) = self.selection_projection_cache.as_ref() else { + return; + }; + if cache + .computed + .get(index) + .copied() + .unwrap_or(/*default*/ true) + { + return; + } + let Some(cell) = self.cells.get(index) else { + return; + }; + let separator = if index > 0 && cell.is_stream_continuation() { + "\n" + } else { + "\n\n" + }; + let projection = cell + .selection_projection(cache.width, cache.render_mode) + .map(|projection| projection.with_separator_before(separator)); + if let Some(cache) = self.selection_projection_cache.as_mut() { + cache.projections[index] = projection; + cache.computed[index] = true; + } + } + + fn ensure_selected_projections(&mut self) { + let Some(selected_cells) = self.selection.selected_cell_span() else { + return; + }; + for index in selected_cells { + self.ensure_cell_projection(index); + } + } + + fn selection_point( + &mut self, + area: Rect, + position: Position, + clamp: bool, + ) -> Option { + if area.is_empty() { + return None; + } + self.ensure_selection_projections(area.width); + let column = position + .x + .clamp(area.x, area.right().saturating_sub(1)) + .saturating_sub(area.x); + let screen_row = position + .y + .clamp(area.y, area.bottom().saturating_sub(1)) + .saturating_sub(area.y); + if !clamp && !area.contains(position) { + return None; + } + let content_row = self + .content + .scroll_offset() + .saturating_add(usize::from(screen_row)); + let target = self + .selection_projection_cache + .as_ref()? + .layout + .iter() + .position(|layout| { + layout.height > 0 + && content_row >= layout.top + && content_row < layout.top.saturating_add(layout.height) + }); + if let Some(index) = target { + self.ensure_cell_projection(index); + let cache = self.selection_projection_cache.as_ref()?; + let layout = cache.layout[index]; + let projection = cache.projections[index].as_ref(); + let local_row = content_row.saturating_sub(layout.top); + let bytes = projection.and_then(|projection| { + if clamp { + projection + .closest_hit(local_row, column) + .or_else(|| projection.closest_hit_in_any_row(local_row, column)) + } else { + projection.hit(local_row, column) + } + }); + if let Some(bytes) = bytes { + return Some(SelectionPoint { cell: index, bytes }); + } + } + if !clamp { + return None; + } + + let mut candidates = self + .selection_projection_cache + .as_ref()? + .layout + .iter() + .enumerate() + .filter(|(index, layout)| Some(*index) != target && layout.height > 0) + .map(|(index, layout)| { + let distance = if content_row < layout.top { + layout.top - content_row + } else { + content_row + .saturating_sub(layout.top.saturating_add(layout.height).saturating_sub(1)) + }; + (distance, index) + }) + .collect::>(); + candidates.sort_unstable(); + for (_, index) in candidates { + self.ensure_cell_projection(index); + let cache = self.selection_projection_cache.as_ref()?; + let layout = cache.layout[index]; + let local_row = content_row + .saturating_sub(layout.top) + .min(layout.height.saturating_sub(1)); + if let Some(bytes) = cache.projections[index] + .as_ref() + .and_then(|projection| projection.closest_hit_in_any_row(local_row, column)) + { + return Some(SelectionPoint { cell: index, bytes }); + } + } + None + } + + fn render_selection(&self, area: Rect, buf: &mut Buffer) { + let Some(cache) = self.selection_projection_cache.as_ref() else { + return; + }; + let Some(selected_cells) = self.selection.selected_cell_span() else { + return; + }; + let scroll_offset = self.content.scroll_offset(); + for cell_index in selected_cells { + let Some(layout) = cache.layout.get(cell_index).copied() else { + continue; + }; + let Some(projection) = cache.projections.get(cell_index).and_then(Option::as_ref) + else { + continue; + }; + let Some(selected_bytes) = self + .selection + .selected_bytes_for_cell(cell_index, projection.text().len()) + else { + continue; + }; + for (row_index, row) in projection.rows().iter().enumerate() { + let content_row = layout.top.saturating_add(row_index); + let Some(screen_row) = content_row.checked_sub(scroll_offset) else { + continue; + }; + let Ok(screen_row) = u16::try_from(screen_row) else { + continue; + }; + if screen_row >= area.height { + continue; + } + for segment in &row.segments { + if segment.bytes.start >= selected_bytes.end + || segment.bytes.end <= selected_bytes.start + { + continue; + } + for column in segment.columns.clone() { + if column < area.width { + buf[( + area.x.saturating_add(column), + area.y.saturating_add(screen_row), + )] + .modifier + .insert(Modifier::REVERSED); + } + } + } + } + } + } } struct ConversationCellRenderable { diff --git a/codex-rs/tui/src/conversation_viewport_tests.rs b/codex-rs/tui/src/conversation_viewport_tests.rs index 5697c70f10..ffb8aa5956 100644 --- a/codex-rs/tui/src/conversation_viewport_tests.rs +++ b/codex-rs/tui/src/conversation_viewport_tests.rs @@ -6,8 +6,10 @@ use pretty_assertions::assert_eq; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; +use ratatui::layout::Position; use ratatui::layout::Rect; use ratatui::style::Color; +use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line; @@ -83,6 +85,36 @@ fn viewport(cells: Vec>) -> ConversationViewport { ) } +fn select_entire_projection(projection: CellSelectionProjection) -> String { + let first_bytes = projection + .rows() + .iter() + .find_map(|row| row.segments.first()) + .expect("projection should have a first selectable grapheme") + .bytes + .clone(); + let last_bytes = projection + .rows() + .iter() + .rev() + .find_map(|row| row.segments.last()) + .expect("projection should have a last selectable grapheme") + .bytes + .clone(); + let mut selection = ConversationSelection::default(); + selection.start(SelectionPoint { + cell: 0, + bytes: first_bytes, + }); + selection.update(SelectionPoint { + cell: 0, + bytes: last_bytes, + }); + selection + .finish(/*point*/ None, &[Some(projection)]) + .expect("dragging the full projection should select text") +} + #[test] fn renders_main_display_and_live_tail_without_pager_chrome() { let cells: Vec> = vec![Arc::new(TestCell { @@ -443,6 +475,239 @@ LATEST "###); } +#[test] +fn selection_hit_testing_accounts_for_scroll_offset_and_cell_spacing() { + let mut viewport = viewport(vec![cell("oldest"), cell("LATEST")]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 2, + ); + viewport.render(area, &mut Buffer::empty(area)); + + assert!(!viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0))); + assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 1),)); + assert!(viewport.update_selection(area, Position::new(/*x*/ 5, /*y*/ 1),)); + + let mut highlighted = Buffer::empty(area); + viewport.render(area, &mut highlighted); + for column in 0..=5 { + assert!( + highlighted[(column, 1)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + } + assert_eq!( + viewport.finish_selection(area, Position::new(/*x*/ 5, /*y*/ 1)), + Some("LATEST".to_string()) + ); +} + +#[test] +fn release_beyond_the_last_drag_lazily_projects_the_full_cell_span() { + let mut viewport = viewport(vec![cell("first"), cell("second"), cell("third")]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 5, + ); + viewport.render(area, &mut Buffer::empty(area)); + assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),)); + assert!(viewport.update_selection(area, Position::new(/*x*/ 2, /*y*/ 0),)); + + assert_eq!( + viewport.finish_selection(area, Position::new(/*x*/ 4, /*y*/ 4)), + Some("first\n\nsecond\n\nthird".to_string()) + ); +} + +#[test] +fn replacing_cells_cancels_an_active_selection() { + let mut viewport = viewport(vec![cell("old")]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 2, + ); + viewport.render(area, &mut Buffer::empty(area)); + assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),)); + + viewport.replace_cells(vec![cell("replacement")]); + + assert!(!viewport.selection_is_active()); + assert_eq!( + viewport.finish_selection(area, Position::new(/*x*/ 3, /*y*/ 0)), + None + ); +} + +#[test] +fn animation_only_live_tail_update_preserves_an_active_selection() { + let mut viewport = viewport(vec![cell("committed")]); + viewport.sync_live_tail( + /*width*/ 20, + Some(ActiveCellRenderKey { + revision: 1, + is_stream_continuation: false, + animation_tick: Some(1), + }), + |_| Some(vec![HyperlinkLine::from("live one")]), + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 4, + ); + viewport.render(area, &mut Buffer::empty(area)); + assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),)); + + viewport.sync_live_tail( + /*width*/ 20, + Some(ActiveCellRenderKey { + revision: 1, + is_stream_continuation: false, + animation_tick: Some(2), + }), + |_| { + Some(vec![HyperlinkLine::from( + "live two now wraps across several terminal rows", + )]) + }, + ); + viewport.render(area, &mut Buffer::empty(area)); + + assert!(viewport.selection_is_active()); + assert!(viewport.update_selection(area, Position::new(/*x*/ 8, /*y*/ 0),)); + assert_eq!( + viewport.finish_selection(area, Position::new(/*x*/ 8, /*y*/ 0)), + Some("committed".to_string()) + ); +} + +#[test] +fn user_selection_is_source_exact_at_narrow_and_wide_widths() { + let source = "界 x — visit https://example.com/a/very/long/path and keep going"; + let cell = UserHistoryCell { + message: source.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }; + + for width in [18, 80] { + let projection = cell + .selection_projection(width, HistoryRenderMode::Rich) + .expect("user message should be selectable"); + assert_eq!(select_entire_projection(projection), source); + } +} + +#[test] +fn agent_markdown_selection_is_semantic_and_width_invariant() { + let destination = "https://example.com/a/very/long/path"; + let markdown = format!("**界 x** — [site]({destination})\n\n`code` stays"); + let expected = format!("界 x — site ({destination})\n\ncode stays"); + let cell = AgentMarkdownCell::new(markdown, std::path::Path::new("/tmp")); + + for width in [18, 80] { + let projection = cell + .selection_projection(width, HistoryRenderMode::Rich) + .expect("agent Markdown should be selectable"); + assert_eq!(select_entire_projection(projection), expected); + } +} + +#[test] +fn agent_markdown_partial_selection_uses_screen_coordinates_and_semantic_link_text() { + let destination = "https://example.com/z"; + let markdown = format!("before **bold** [Ω]({destination}) after"); + let cell: Arc = Arc::new(AgentMarkdownCell::new( + markdown, + std::path::Path::new("/tmp"), + )); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 18, /*height*/ 6, + ); + let mut viewport = viewport(vec![cell]); + let mut buffer = Buffer::empty(area); + viewport.render(area, &mut buffer); + let find_symbol = |needle: char| { + (area.y..area.bottom()) + .flat_map(|y| (area.x..area.right()).map(move |x| Position::new(x, y))) + .find(|position| { + crate::terminal_hyperlinks::strip_osc8(buffer[(position.x, position.y)].symbol()) + == needle.to_string() + }) + .unwrap_or_else(|| panic!("rendered Markdown should contain {needle}")) + }; + let start = find_symbol('Ω'); + let end = find_symbol(')'); + + assert!(viewport.begin_selection(area, start)); + assert!(viewport.update_selection(area, end)); + assert_eq!( + viewport.finish_selection(area, end), + Some(format!("Ω ({destination})")) + ); +} + +#[test] +fn agent_tables_require_raw_mode_until_layout_exposes_source_ranges() { + let markdown = "| Name | Description |\n| --- | --- |\n| alpha | a value that wraps over several words |\n| beta | short |"; + let cell = AgentMarkdownCell::new(markdown.to_string(), std::path::Path::new("/tmp")); + + for width in [12, 80] { + assert!( + cell.selection_projection(width, HistoryRenderMode::Rich) + .is_none(), + "rich table layout cannot yet expose exact source ranges at width {width}", + ); + } + let projection = cell + .selection_projection(/*width*/ 24, HistoryRenderMode::Raw) + .expect("raw Markdown should remain selectable"); + assert_eq!(select_entire_projection(projection), markdown); +} + +#[test] +fn selected_user_prompt_highlights_source_glyphs_without_chrome_or_padding() { + let width = 18; + let source = "alpha beta gamma delta"; + let cell: Arc = Arc::new(UserHistoryCell { + message: source.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }); + let height = cell.desired_height_for_mode(width, HistoryRenderMode::Rich); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut viewport = viewport(vec![cell]); + viewport.render(area, &mut Buffer::empty(area)); + assert!(viewport.begin_selection(area, Position::new(/*x*/ 2, /*y*/ 1),)); + assert!(viewport.update_selection(area, Position::new(/*x*/ 12, /*y*/ 2),)); + + let mut highlighted = Buffer::empty(area); + viewport.render(area, &mut highlighted); + let text = buffer_text(&highlighted, area) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + let mask = (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| { + if highlighted[(x, y)].modifier.contains(Modifier::REVERSED) { + '#' + } else { + '.' + } + }) + .collect::() + }) + .collect::>() + .join("\n"); + + assert_snapshot!(format!("text:\n{text}\nselection:\n{mask}")); + assert_eq!( + viewport.finish_selection(area, Position::new(/*x*/ 12, /*y*/ 2)), + Some(source.to_string()) + ); +} + fn buffer_text(buffer: &Buffer, area: Rect) -> String { let mut rows = Vec::new(); for y in area.y..area.bottom() { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 2f05d33af1..98d930cc37 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -373,10 +373,16 @@ impl PagerView { /// Render only scrollable content, without pager header, footer, or empty-row markers. /// /// This is the shared rendering primitive for application-owned views that provide their own - /// chrome. A view that was following the bottom stays pinned there when wrapping or the - /// available height changes. - fn render_content_only(&mut self, area: Rect, buf: &mut Buffer) { - let follow_bottom = self.is_scrolled_to_bottom(); + /// chrome. Callers can keep normal bottom-follow behavior or freeze the current offset during + /// a pointer gesture. + fn render_content_only( + &mut self, + area: Rect, + buf: &mut Buffer, + bottom_follow: BottomFollowMode, + ) { + let follow_bottom = + bottom_follow == BottomFollowMode::Enabled && self.is_scrolled_to_bottom(); Clear.render(area, buf); self.update_last_content_height(area.height); let content_height = self.content_height(area.width); @@ -444,6 +450,12 @@ pub(crate) struct PagerContent { view: PagerView, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BottomFollowMode { + Enabled, + Frozen, +} + impl PagerContent { pub(crate) fn new(renderables: Vec>, keymap: PagerKeymap) -> Self { Self { @@ -456,8 +468,8 @@ impl PagerContent { } } - pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { - self.view.render_content_only(area, buf); + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, bottom_follow: BottomFollowMode) { + self.view.render_content_only(area, buf, bottom_follow); } pub(crate) fn replace(&mut self, renderables: Vec>) { @@ -476,6 +488,14 @@ impl PagerContent { self.view.renderables.len() } + pub(crate) fn renderable_heights(&self, width: u16) -> Vec { + self.view + .renderables + .iter() + .map(|renderable| renderable.desired_height(width)) + .collect() + } + pub(crate) fn is_following_bottom(&self) -> bool { self.view.is_scrolled_to_bottom() } @@ -499,6 +519,10 @@ impl PagerContent { pub(crate) fn handle_mouse_scroll(&mut self, direction: MouseScrollDirection) { self.view.apply_mouse_scroll(direction); } + + pub(crate) fn scroll_offset(&self) -> usize { + self.view.scroll_offset + } } /// A renderable that caches its desired height. diff --git a/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__selected_user_prompt_highlights_source_glyphs_without_chrome_or_padding.snap b/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__selected_user_prompt_highlights_source_glyphs_without_chrome_or_padding.snap new file mode 100644 index 0000000000..260fbebe59 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__selected_user_prompt_highlights_source_glyphs_without_chrome_or_padding.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/conversation_viewport_tests.rs +expression: "format!(\"text:\\n{text}\\nselection:\\n{mask}\")" +--- +text: + +› alpha beta + gamma delta + +selection: +.................. +..##########...... +..###########..... +..................