mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
feat(tui): select retained conversation text
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+14
@@ -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:
|
||||
..................
|
||||
..##########......
|
||||
..###########.....
|
||||
..................
|
||||
Reference in New Issue
Block a user