From 1077203e3608ca66ad396bc330e7f216d0405540 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 4 Jul 2026 23:36:10 -0300 Subject: [PATCH] feat(tui): map rendered cells to source text --- codex-rs/tui/src/conversation_selection.rs | 357 +++++++++++++++++ .../tui/src/conversation_selection_tests.rs | 359 ++++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + 3 files changed, 717 insertions(+) create mode 100644 codex-rs/tui/src/conversation_selection.rs create mode 100644 codex-rs/tui/src/conversation_selection_tests.rs diff --git a/codex-rs/tui/src/conversation_selection.rs b/codex-rs/tui/src/conversation_selection.rs new file mode 100644 index 0000000000..251e678139 --- /dev/null +++ b/codex-rs/tui/src/conversation_selection.rs @@ -0,0 +1,357 @@ +//! Source-backed selection for the retained conversation viewport. +//! +//! Mouse coordinates are layout data, not clipboard data. A projection maps rendered terminal +//! cells back to ranges in canonical semantic text; copied output is always sliced from that text. + +use std::ops::Range; +use std::sync::Arc; + +use ratatui::buffer::Buffer; +use ratatui::buffer::Cell; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SelectionSegment { + pub(crate) columns: Range, + pub(crate) bytes: Range, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct SelectionRow { + pub(crate) segments: Vec, +} + +/// Canonical copy text plus a per-rendered-row mapping back into that text. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CellSelectionProjection { + text: Arc, + rows: Vec, + separator_before: &'static str, +} + +#[derive(Clone, Debug)] +struct SemanticGrapheme<'a> { + text: &'a str, + bytes: Range, +} + +impl CellSelectionProjection { + /// Build a projection by aligning the cell's rendered graphemes with semantic source text. + /// + /// `first_row_prefix_columns` marks presentation-only chrome prepended to every logical line. + /// It applies only to the first terminal row of a logical line because Ratatui may perform one + /// final character wrap for an overlong token. + pub(crate) fn from_display_lines( + text: String, + lines: Vec>, + width: u16, + first_row_prefix_columns: u16, + ) -> Option { + if text.is_empty() || width == 0 || lines.is_empty() { + return None; + } + + let semantic = semantic_graphemes(&text); + let mut semantic_cursor = 0; + let mut rows = Vec::new(); + + for line in lines { + let paragraph = Paragraph::new(line).wrap(Wrap { trim: false }); + let height = paragraph + .line_count(width) + .max(1) + .try_into() + .unwrap_or(u16::MAX); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + const UNTOUCHED: &str = "\0"; + let mut buffer = Buffer::filled(area, Cell::new(UNTOUCHED)); + paragraph.render(area, &mut buffer); + + for row_index in 0..height { + let mut row = SelectionRow::default(); + let mut column = 0; + while column < width { + let cell = &buffer[(column, row_index)]; + let symbol = cell.symbol(); + if cell.skip || symbol == UNTOUCHED { + column = column.saturating_add(1); + continue; + } + if symbol.is_empty() { + column = column.saturating_add(1); + continue; + } + let symbol_width = symbol.width().max(1).min(usize::from(u16::MAX)) as u16; + let columns = column..column.saturating_add(symbol_width).min(width); + if row_index == 0 && columns.start < first_row_prefix_columns { + column = column.saturating_add(symbol_width); + continue; + } + + let Some(source_index) = + matching_semantic_index(&semantic, semantic_cursor, symbol) + else { + column = column.saturating_add(symbol_width); + continue; + }; + let source = &semantic[source_index]; + row.segments.push(SelectionSegment { + columns, + bytes: source.bytes.clone(), + }); + semantic_cursor = source_index.saturating_add(1); + column = column.saturating_add(symbol_width); + } + rows.push(row); + } + } + + if rows.iter().all(|row| row.segments.is_empty()) { + return None; + } + + Some(Self { + text: Arc::from(text), + rows, + separator_before: "\n\n", + }) + } + + pub(crate) fn with_separator_before(mut self, separator_before: &'static str) -> Self { + self.separator_before = separator_before; + self + } + + pub(crate) fn text(&self) -> &str { + &self.text + } + + pub(crate) fn rows(&self) -> &[SelectionRow] { + &self.rows + } + + pub(crate) fn hit(&self, row: usize, column: u16) -> Option> { + self.rows.get(row)?.segments.iter().find_map(|segment| { + segment + .columns + .contains(&column) + .then(|| segment.bytes.clone()) + }) + } + + pub(crate) fn closest_hit(&self, row: usize, column: u16) -> Option> { + if let Some(hit) = self.hit(row, column) { + return Some(hit); + } + let segments = &self.rows.get(row)?.segments; + let first = segments.first()?; + if column < first.columns.start { + return Some(first.bytes.start..first.bytes.start); + } + let last = segments.last()?; + if column >= last.columns.end { + return Some(last.bytes.end..last.bytes.end); + } + + segments.windows(2).find_map(|pair| { + let [left, right] = pair else { + return None; + }; + if column < left.columns.end || column >= right.columns.start { + return None; + } + let left_distance = column.saturating_sub(left.columns.end); + let right_distance = right.columns.start.saturating_sub(column); + if left_distance < right_distance { + Some(left.bytes.end..left.bytes.end) + } else { + Some(right.bytes.start..right.bytes.start) + } + }) + } + + pub(crate) fn closest_hit_in_any_row(&self, row: usize, column: u16) -> Option> { + self.rows + .iter() + .enumerate() + .filter_map(|(candidate_row, selection_row)| { + let first = selection_row.segments.first()?; + let last = selection_row.segments.last()?; + let column_distance = if column < first.columns.start { + first.columns.start - column + } else if column >= last.columns.end { + column.saturating_sub(last.columns.end.saturating_sub(1)) + } else { + 0 + }; + Some(( + (candidate_row.abs_diff(row), column_distance), + candidate_row, + )) + }) + .min_by_key(|(distance, _)| *distance) + .and_then(|(_, candidate_row)| self.closest_hit(candidate_row, column)) + } +} + +fn semantic_graphemes(text: &str) -> Vec> { + text.grapheme_indices(/*is_extended*/ true) + .map(|(start, grapheme)| SemanticGrapheme { + text: grapheme, + bytes: start..start + grapheme.len(), + }) + .collect() +} + +fn matching_semantic_index( + semantic: &[SemanticGrapheme<'_>], + cursor: usize, + rendered: &str, +) -> Option { + let current = semantic.get(cursor)?; + if current.text == rendered { + return Some(cursor); + } + if rendered.chars().all(char::is_whitespace) { + return current + .text + .chars() + .all(|character| character.is_whitespace() && character != '\n' && character != '\r') + .then_some(cursor); + } + + for (index, grapheme) in semantic.iter().enumerate().skip(cursor) { + if grapheme.text == rendered { + return Some(index); + } + if index != cursor && !grapheme.text.chars().all(char::is_whitespace) { + break; + } + } + None +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SelectionPoint { + pub(crate) cell: usize, + pub(crate) bytes: Range, +} + +#[derive(Default)] +pub(crate) struct ConversationSelection { + anchor: Option, + focus: Option, + dragged: bool, +} + +impl ConversationSelection { + pub(crate) fn start(&mut self, point: SelectionPoint) { + self.anchor = Some(point.clone()); + self.focus = Some(point); + self.dragged = false; + } + + pub(crate) fn update(&mut self, point: SelectionPoint) -> bool { + if self.anchor.is_none() { + return false; + } + self.focus = Some(point); + self.dragged = true; + true + } + + pub(crate) fn finish( + &mut self, + point: Option, + projections: &[Option], + ) -> Option { + self.set_release_point(point); + let text = self + .dragged + .then(|| self.selected_text(projections)) + .flatten(); + self.cancel(); + text.filter(|text| !text.is_empty()) + } + + pub(crate) fn set_release_point(&mut self, point: Option) { + if let Some(point) = point { + self.focus = Some(point); + } + } + + pub(crate) fn cancel(&mut self) { + self.anchor = None; + self.focus = None; + self.dragged = false; + } + + pub(crate) fn is_active(&self) -> bool { + self.anchor.is_some() + } + + pub(crate) fn selected_cell_span(&self) -> Option> { + let (start, end) = self.ordered_points()?; + Some(start.cell..end.cell.saturating_add(1)) + } + + pub(crate) fn selected_bytes_for_cell( + &self, + cell: usize, + text_len: usize, + ) -> Option> { + let (start, end) = self.ordered_points()?; + if cell < start.cell || cell > end.cell { + return None; + } + let range = if start.cell == end.cell { + start.bytes.start..end.bytes.end + } else if cell == start.cell { + start.bytes.start..text_len + } else if cell == end.cell { + 0..end.bytes.end + } else { + 0..text_len + }; + (range.start < range.end).then_some(range) + } + + fn selected_text(&self, projections: &[Option]) -> Option { + let (start, end) = self.ordered_points()?; + let mut selected = String::new(); + for cell in start.cell..=end.cell { + let projection = projections.get(cell)?.as_ref()?; + let Some(range) = self.selected_bytes_for_cell(cell, projection.text().len()) else { + continue; + }; + let Some(fragment) = projection.text().get(range) else { + continue; + }; + if !selected.is_empty() { + selected.push_str(projection.separator_before); + } + selected.push_str(fragment); + } + Some(selected) + } + + fn ordered_points(&self) -> Option<(&SelectionPoint, &SelectionPoint)> { + let anchor = self.anchor.as_ref()?; + let focus = self.focus.as_ref()?; + if (anchor.cell, anchor.bytes.start) <= (focus.cell, focus.bytes.start) { + Some((anchor, focus)) + } else { + Some((focus, anchor)) + } + } +} + +#[cfg(test)] +#[path = "conversation_selection_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/conversation_selection_tests.rs b/codex-rs/tui/src/conversation_selection_tests.rs new file mode 100644 index 0000000000..87acf60505 --- /dev/null +++ b/codex-rs/tui/src/conversation_selection_tests.rs @@ -0,0 +1,359 @@ +use pretty_assertions::assert_eq; +use ratatui::text::Line; + +use super::*; + +fn projection( + text: &str, + rendered: impl IntoIterator, + width: u16, + prefix_columns: u16, +) -> CellSelectionProjection { + CellSelectionProjection::from_display_lines( + text.to_string(), + rendered.into_iter().map(Line::from).collect(), + width, + prefix_columns, + ) + .expect("projection should contain selectable text") +} + +fn point( + projection: &CellSelectionProjection, + cell: usize, + row: usize, + column: u16, +) -> SelectionPoint { + SelectionPoint { + cell, + bytes: projection + .hit(row, column) + .expect("coordinate should map to source text"), + } +} + +#[test] +fn soft_wrapped_selection_returns_original_space_without_newline() { + let projection = projection( + "alpha beta", + ["› alpha", " beta"], + /*width*/ 20, + /*prefix_columns*/ 2, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 2, + )); + selection.update(point( + &projection, + /*cell*/ 0, + /*row*/ 1, + /*column*/ 5, + )); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(projection)]), + Some("alpha beta".to_string()) + ); +} + +#[test] +fn authored_newline_is_preserved_across_rendered_rows() { + let projection = projection( + "alpha\nbeta", + ["› alpha", " beta"], + /*width*/ 20, + /*prefix_columns*/ 2, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 2, + )); + selection.update(point( + &projection, + /*cell*/ 0, + /*row*/ 1, + /*column*/ 5, + )); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(projection)]), + Some("alpha\nbeta".to_string()) + ); +} + +#[test] +fn backward_drag_normalizes_to_the_same_source_range() { + let projection = projection( + "alpha beta", + ["› alpha", " beta"], + /*width*/ 20, + /*prefix_columns*/ 2, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &projection, + /*cell*/ 0, + /*row*/ 1, + /*column*/ 5, + )); + selection.update(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 2, + )); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(projection)]), + Some("alpha beta".to_string()) + ); +} + +#[test] +fn tabs_are_copied_from_source_instead_of_visual_spaces() { + let projection = projection( + "a\tb", + ["a b"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 0, + )); + selection.update(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 5, + )); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(projection)]), + Some("a\tb".to_string()) + ); +} + +#[test] +fn wide_and_combining_graphemes_map_as_complete_utf8_ranges() { + let projection = projection( + "ē•Œe\u{301}šŸ™‚", + ["ē•ŒeĢšŸ™‚"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), Some(0.."ē•Œ".len())); + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), Some(0.."ē•Œ".len())); + assert_eq!( + projection.hit(/*row*/ 0, /*column*/ 2), + Some("ē•Œ".len().."ē•Œe\u{301}".len()) + ); +} + +#[test] +fn presentation_prefix_and_padding_have_no_source_mapping() { + let projection = projection( + "hello", + ["› hello"], + /*width*/ 20, + /*prefix_columns*/ 2, + ); + + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), None); + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), None); + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 8), None); +} + +#[test] +fn soft_wrap_padding_does_not_consume_an_invisible_source_space() { + let projection = projection( + "alpha beta", + ["alpha beta"], + /*width*/ 8, + /*prefix_columns*/ 0, + ); + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 5), None); + + let mut selection = ConversationSelection::default(); + selection.start(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 0, + )); + selection.update(SelectionPoint { + cell: 0, + bytes: projection + .closest_hit(/*row*/ 0, /*column*/ 7) + .expect("padding should clamp to the last rendered grapheme"), + }); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(projection)]), + Some("alpha".to_string()) + ); +} + +#[test] +fn clamping_to_a_wrapped_prefix_stops_before_the_first_source_glyph() { + let projection = projection( + "alpha beta", + ["› alpha", " beta"], + /*width*/ 20, + /*prefix_columns*/ 2, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 2, + )); + selection.update(SelectionPoint { + cell: 0, + bytes: projection + .closest_hit(/*row*/ 1, /*column*/ 0) + .expect("the prefix should clamp to the boundary before beta"), + }); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(projection)]), + Some("alpha ".to_string()) + ); +} + +#[test] +fn wide_grapheme_continuation_does_not_consume_following_space() { + let projection = projection( + "ē•Œ x", + ["ē•Œ x"], + /*width*/ 8, + /*prefix_columns*/ 0, + ); + + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), Some(0.."ē•Œ".len())); + assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), Some(0.."ē•Œ".len())); + assert_eq!( + projection.hit(/*row*/ 0, /*column*/ 2), + Some("ē•Œ".len().."ē•Œ ".len()) + ); + assert_eq!( + projection.hit(/*row*/ 0, /*column*/ 3), + Some("ē•Œ ".len().."ē•Œ x".len()) + ); +} + +#[test] +fn cross_cell_selection_uses_explicit_message_separator() { + let first = projection( + "first", + ["first"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + let second = projection( + "second", + ["second"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &first, /*cell*/ 0, /*row*/ 0, /*column*/ 0, + )); + selection.update(point( + &second, /*cell*/ 1, /*row*/ 0, /*column*/ 5, + )); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(first), Some(second)]), + Some("first\n\nsecond".to_string()) + ); +} + +#[test] +fn stream_continuation_uses_a_single_authored_line_break() { + let first = projection( + "first", + ["first"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + let second = projection( + "second", + ["second"], + /*width*/ 20, + /*prefix_columns*/ 0, + ) + .with_separator_before("\n"); + let mut selection = ConversationSelection::default(); + selection.start(point( + &first, /*cell*/ 0, /*row*/ 0, /*column*/ 0, + )); + selection.update(point( + &second, /*cell*/ 1, /*row*/ 0, /*column*/ 5, + )); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(first), Some(second)]), + Some("first\nsecond".to_string()) + ); +} + +#[test] +fn selection_does_not_silently_skip_an_unsupported_cell() { + let first = projection( + "first", + ["first"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + let third = projection( + "third", + ["third"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &first, /*cell*/ 0, /*row*/ 0, /*column*/ 0, + )); + selection.update(point( + &third, /*cell*/ 2, /*row*/ 0, /*column*/ 4, + )); + + assert_eq!( + selection.finish(/*point*/ None, &[Some(first), None, Some(third)],), + None + ); +} + +#[test] +fn click_without_drag_does_not_copy() { + let projection = projection( + "hello", + ["hello"], + /*width*/ 20, + /*prefix_columns*/ 0, + ); + let mut selection = ConversationSelection::default(); + selection.start(point( + &projection, + /*cell*/ 0, + /*row*/ 0, + /*column*/ 0, + )); + + assert_eq!(selection.finish(/*point*/ None, &[Some(projection)]), None); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 25551dccc1..4966a8d625 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -105,6 +105,7 @@ mod clipboard_paste; mod collaboration_modes; mod color; mod config_update; +mod conversation_selection; mod conversation_viewport; pub(crate) mod custom_terminal; mod pets;