feat(tui): select retained conversation text

This commit is contained in:
Felipe Coury
2026-07-04 23:37:41 -03:00
parent 11a48b6460
commit e1ce98870f
4 changed files with 633 additions and 8 deletions
+324 -2
View File
@@ -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<Arc<dyn HistoryCell>>,
render_mode: HistoryRenderMode,
live_tail_key: Option<LiveTailKey>,
selection: ConversationSelection,
selection_scroll_offset: Option<usize>,
selection_projection_cache: Option<SelectionProjectionCache>,
}
struct SelectionProjectionCache {
width: u16,
render_mode: HistoryRenderMode,
projections: Vec<Option<CellSelectionProjection>>,
computed: Vec<bool>,
layout: Vec<SelectionCellLayout>,
}
#[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<String> {
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<dyn HistoryCell>) {
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<Arc<dyn HistoryCell>>) {
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<Box<dyn Renderable>> {
(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<SelectionPoint> {
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::<Vec<_>>();
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 {
@@ -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<Arc<dyn HistoryCell>>) -> 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<Arc<dyn HistoryCell>> = 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<dyn HistoryCell> = 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<dyn HistoryCell> = 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::<Vec<_>>()
.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::<String>()
})
.collect::<Vec<_>>()
.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() {
+30 -6
View File
@@ -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<Box<dyn Renderable>>, 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<Box<dyn Renderable>>) {
@@ -476,6 +488,14 @@ impl PagerContent {
self.view.renderables.len()
}
pub(crate) fn renderable_heights(&self, width: u16) -> Vec<u16> {
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.
@@ -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:
..................
..##########......
..###########.....
..................