fix(tui): align transcript selection with visible prompts

This commit is contained in:
Felipe Coury
2026-06-01 10:59:57 -03:00
parent d9eb01a583
commit e4dc5a701c
4 changed files with 324 additions and 62 deletions
+28 -8
View File
@@ -433,26 +433,46 @@ impl App {
return Ok(());
}
let (copy_selection, copy_latest, close_overlay) = if let Some(overlay) = &mut self.overlay
let (scroll_selection, copy_selection, copy_latest, close_overlay) = if let Some(overlay) =
&mut self.overlay
{
overlay.handle_event(tui, event)?;
let (copy_selection, copy_latest) = match overlay {
let (scroll_selection, copy_selection, copy_latest) = match overlay {
Overlay::Transcript(transcript) => {
let scroll_selection = transcript.take_scroll_selected_user_cell();
if transcript.take_copy_requested() {
match transcript.selected_user_cell() {
Some(user_cell_idx) => (Some(user_cell_idx), false),
None => (None, true),
Some(user_cell_idx) => (scroll_selection, Some(user_cell_idx), false),
None => (scroll_selection, None, true),
}
} else {
(None, false)
(scroll_selection, None, false)
}
}
Overlay::Static(_) => (None, false),
Overlay::Static(_) => (None, None, false),
};
(copy_selection, copy_latest, overlay.is_done())
(
scroll_selection,
copy_selection,
copy_latest,
overlay.is_done(),
)
} else {
(None, false, false)
(None, None, false, false)
};
if let Some(user_cell_idx) = scroll_selection
&& self
.transcript_cells
.get(user_cell_idx)
.is_some_and(|cell| cell.is_user_prompt())
&& let Some(nth_user_message) =
user_count(&self.transcript_cells[..=user_cell_idx]).checked_sub(1)
{
self.backtrack.primed = true;
self.backtrack.base_id = self.chat_widget.thread_id();
self.backtrack.overlay_preview_active = true;
self.backtrack.nth_user_message = nth_user_message;
}
let copy_status = if let Some(user_cell_idx) = copy_selection {
Some(self.copy_transcript_turn(user_cell_idx))
} else if copy_latest {
+263 -54
View File
@@ -151,8 +151,8 @@ struct PagerView {
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
layout: Option<PagerLayout>,
/// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>,
/// If set, on next render position this row of the chunk near the upper third.
pending_anchor_chunk: Option<(usize, usize)>,
}
#[derive(Debug)]
@@ -180,7 +180,7 @@ impl PagerView {
last_content_height: None,
last_rendered_height: None,
layout: None,
pending_scroll_chunk: None,
pending_anchor_chunk: None,
}
}
@@ -225,15 +225,8 @@ impl PagerView {
self.update_last_content_height(content_area.height);
let content_height = self.content_height(content_area.width);
self.last_rendered_height = Some(content_height);
self.resolve_pending_scroll(content_area, content_height);
self.render_header(area, content_area, buf, content_height);
// If there is a pending request to scroll a specific chunk into view,
// satisfy it now that wrapping is up to date for this width.
if let Some(idx) = self.pending_scroll_chunk.take() {
self.ensure_chunk_visible(idx, content_area);
}
self.scroll_offset = self
.scroll_offset
.min(content_height.saturating_sub(content_area.height as usize));
self.render_content(content_area, buf);
@@ -331,7 +324,7 @@ impl PagerView {
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8
}
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<bool> {
match key_event {
e if self.keymap.scroll_up.is_pressed(e) => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
@@ -364,12 +357,12 @@ impl PagerView {
self.scroll_offset = usize::MAX;
}
_ => {
return Ok(());
return Ok(false);
}
}
tui.frame_requester()
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
Ok(())
Ok(true)
}
/// Returns the height of one page in content rows.
@@ -415,25 +408,35 @@ impl PagerView {
self.scroll_offset >= max_scroll
}
/// Request that the given text chunk index be scrolled into view on next render.
fn scroll_chunk_into_view(&mut self, chunk_index: usize) {
self.pending_scroll_chunk = Some(chunk_index);
fn resolve_pending_scroll(&mut self, area: Rect, content_height: usize) {
if let Some((idx, row_offset)) = self.pending_anchor_chunk.take() {
self.position_chunk_at_upper_third(idx, row_offset, area);
}
self.scroll_offset = self
.scroll_offset
.min(content_height.saturating_sub(area.height as usize));
}
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
fn clamped_scroll_offset(&mut self, area: Rect) -> usize {
self.scroll_offset.min(
self.content_height(area.width)
.saturating_sub(area.height as usize),
)
}
/// Request that a row inside a selected chunk be anchored on next render.
fn scroll_chunk_to_upper_third(&mut self, chunk_index: usize, row_offset: usize) {
self.pending_anchor_chunk = Some((chunk_index, row_offset));
}
fn position_chunk_at_upper_third(&mut self, idx: usize, row_offset: usize, area: Rect) {
if area.height == 0 || idx >= self.renderables.len() {
return;
}
let layout = self.layout(area.width);
let first = layout.offsets[idx];
let last = first.saturating_add(layout.heights[idx]);
let current_top = self.scroll_offset;
let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
if first < current_top {
self.scroll_offset = first;
} else if last > current_bottom {
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
}
let row = layout.offsets[idx].saturating_add(row_offset);
let anchor = (area.height as usize) / 3;
self.scroll_offset = row.saturating_sub(anchor);
}
}
@@ -526,6 +529,7 @@ pub(crate) struct TranscriptOverlay {
copy_keymap: Vec<KeyBinding>,
toggle_raw_output_keymap: Vec<KeyBinding>,
copy_requested: bool,
scroll_selected_user_cell: Option<usize>,
footer_status: Option<FooterStatus>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
@@ -584,6 +588,7 @@ impl TranscriptOverlay {
copy_keymap,
toggle_raw_output_keymap,
copy_requested: false,
scroll_selected_user_cell: None,
footer_status: None,
live_tail_key: None,
is_done: false,
@@ -787,10 +792,27 @@ impl TranscriptOverlay {
}
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.rebuild_renderables();
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
self.set_highlight_cell_with_placement(cell, PromptSelectionPlacement::AnchorUpperThird);
}
fn set_highlight_cell_preserving_viewport(&mut self, cell: Option<usize>) {
self.set_highlight_cell_with_placement(cell, PromptSelectionPlacement::PreserveViewport);
}
fn set_highlight_cell_with_placement(
&mut self,
cell: Option<usize>,
placement: PromptSelectionPlacement,
) {
let prompt_row_offset = cell.map(|idx| self.prompt_first_text_row_offset(idx));
if self.highlight_cell != cell {
self.highlight_cell = cell;
self.rebuild_renderables();
}
if let (Some(idx), Some(row_offset), PromptSelectionPlacement::AnchorUpperThird) =
(cell, prompt_row_offset, placement)
{
self.view.scroll_chunk_to_upper_third(idx, row_offset);
}
}
@@ -814,6 +836,10 @@ impl TranscriptOverlay {
std::mem::take(&mut self.copy_requested)
}
pub(crate) fn take_scroll_selected_user_cell(&mut self) -> Option<usize> {
self.scroll_selected_user_cell.take()
}
pub(crate) fn show_copy_status(&mut self, status: &CopyStatus, tui: &mut tui::Tui) {
self.show_copy_status_at(status, Instant::now());
tui.frame_requester().schedule_frame();
@@ -868,6 +894,78 @@ impl TranscriptOverlay {
self.set_highlight_cell(Some(next_prompt));
}
fn prompt_first_text_row_offset(&self, idx: usize) -> usize {
let inter_cell_spacing = usize::from(
idx > 0
&& self
.cells
.get(idx)
.is_some_and(|cell| !cell.is_stream_continuation()),
);
let rich_prompt_padding = usize::from(matches!(self.render_mode, HistoryRenderMode::Rich));
inter_cell_spacing.saturating_add(rich_prompt_padding)
}
fn prompt_entering_viewport(
&mut self,
width: u16,
height: u16,
before: usize,
after: usize,
) -> Option<usize> {
if before == after || height == 0 {
return None;
}
let offsets = self.view.layout(width).offsets.clone();
let prompts = self
.cells
.iter()
.enumerate()
.filter(|(_, cell)| cell.is_user_prompt())
.map(|(idx, _)| {
(
idx,
offsets[idx].saturating_add(self.prompt_first_text_row_offset(idx)),
)
});
if after > before {
let previous_bottom = before.saturating_add(height as usize);
let current_bottom = after.saturating_add(height as usize);
prompts
.filter(|(_, row)| previous_bottom <= *row && *row < current_bottom)
.map(|(idx, _)| idx)
.next_back()
} else {
prompts
.filter(|(_, row)| after <= *row && *row < before)
.map(|(idx, _)| idx)
.next()
}
}
fn handle_viewport_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
let top_h = tui.terminal.viewport_area.height.saturating_sub(3);
let top = Rect::new(
tui.terminal.viewport_area.x,
tui.terminal.viewport_area.y,
tui.terminal.viewport_area.width,
top_h,
);
let content_area = self.view.content_area(top);
let before = self.view.clamped_scroll_offset(content_area);
if !self.view.handle_key_event(tui, key_event)? {
return Ok(());
}
let after = self.view.clamped_scroll_offset(content_area);
if let Some(cell_idx) =
self.prompt_entering_viewport(content_area.width, content_area.height, before, after)
{
self.set_highlight_cell_preserving_viewport(Some(cell_idx));
self.scroll_selected_user_cell = Some(cell_idx);
}
Ok(())
}
fn header_title(&self) -> String {
"Transcript".to_string()
}
@@ -1083,6 +1181,7 @@ impl TranscriptOverlay {
self.view.title = self.header_title();
let content_area = self.view.content_area(top);
let total_len = self.view.content_height(content_area.width);
self.view.resolve_pending_scroll(content_area, total_len);
self.view.footer_separator_label =
self.footer_progress_label(content_area.height, total_len, top.width);
self.view.render(top, buf);
@@ -1124,7 +1223,7 @@ impl TranscriptOverlay {
self.copy_requested = true;
Ok(())
}
other => self.view.handle_key_event(tui, other),
other => self.handle_viewport_key_event(tui, other),
}
}
TuiEvent::Draw | TuiEvent::Resize => {
@@ -1151,6 +1250,12 @@ enum PromptSelectionDirection {
Next,
}
#[derive(Clone, Copy)]
enum PromptSelectionPlacement {
AnchorUpperThird,
PreserveViewport,
}
pub(crate) struct StaticOverlay {
view: PagerView,
is_done: bool,
@@ -1234,7 +1339,7 @@ impl StaticOverlay {
self.is_done = true;
Ok(())
}
other => self.view.handle_key_event(tui, other),
other => self.view.handle_key_event(tui, other).map(|_| ()),
},
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
@@ -1595,6 +1700,126 @@ mod tests {
);
}
#[test]
fn transcript_scroll_selects_prompt_when_its_first_line_enters_from_below() {
let mut overlay = transcript_overlay(vec![
user_cell("first"),
Arc::new(TestCell {
lines: (0..8)
.map(|idx| Line::from(format!("answer-{idx}")))
.collect(),
}),
user_cell("second"),
Arc::new(TestCell {
lines: vec![Line::from("after second")],
}),
]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 12,
);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
let top = Rect::new(area.x, area.y, area.width, area.height.saturating_sub(3));
let content_area = overlay.view.content_area(top);
let second_row = {
let layout = overlay.view.layout(content_area.width);
layout.offsets[2].saturating_add(overlay.prompt_first_text_row_offset(2))
};
let before = second_row.saturating_sub(content_area.height as usize);
let after = before.saturating_add(1);
let selected = overlay.prompt_entering_viewport(
content_area.width,
content_area.height,
before,
after,
);
overlay.view.scroll_offset = after;
overlay.set_highlight_cell_preserving_viewport(selected);
overlay.render(area, &mut buf);
assert_eq!(selected, Some(2));
assert_eq!(overlay.selected_user_cell(), Some(2));
assert_eq!(overlay.view.scroll_offset, after);
assert_snapshot!(
"transcript_scroll_selects_prompt_entering_from_below",
buffer_to_text(&buf, area)
);
}
#[test]
fn transcript_scroll_selects_prompt_when_its_first_line_enters_from_above() {
let mut overlay = transcript_overlay(vec![
user_cell("first"),
Arc::new(TestCell {
lines: (0..16)
.map(|idx| Line::from(format!("answer-{idx}")))
.collect(),
}),
]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 12,
);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
let top = Rect::new(area.x, area.y, area.width, area.height.saturating_sub(3));
let content_area = overlay.view.content_area(top);
let selected = overlay.prompt_entering_viewport(
content_area.width,
content_area.height,
/*before*/ 2,
/*after*/ 1,
);
overlay.view.scroll_offset = 1;
overlay.set_highlight_cell_preserving_viewport(selected);
assert_eq!(selected, Some(0));
assert_eq!(overlay.selected_user_cell(), Some(0));
assert_eq!(overlay.view.scroll_offset, 1);
}
#[test]
fn explicit_prompt_selection_anchors_prompt_in_upper_third() {
let mut overlay = transcript_overlay(vec![
user_cell("first"),
Arc::new(TestCell {
lines: (0..12)
.map(|idx| Line::from(format!("before-{idx}")))
.collect(),
}),
user_cell("second"),
Arc::new(TestCell {
lines: (0..12)
.map(|idx| Line::from(format!("after-{idx}")))
.collect(),
}),
]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 15,
);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
overlay.set_highlight_cell(Some(2));
overlay.render(area, &mut buf);
let top = Rect::new(area.x, area.y, area.width, area.height.saturating_sub(3));
let content_area = overlay.view.content_area(top);
let selected_row = {
let layout = overlay.view.layout(content_area.width);
layout.offsets[2].saturating_add(overlay.prompt_first_text_row_offset(2))
};
assert_eq!(
selected_row.saturating_sub(overlay.view.scroll_offset),
(content_area.height as usize) / 3,
);
assert_snapshot!(
"explicit_prompt_selection_anchors_prompt_in_upper_third",
buffer_to_text(&buf, area)
);
}
#[test]
fn selected_user_prompt_keeps_reversed_style_without_role_gutter() {
let mut overlay = transcript_overlay(vec![user_cell("selected prompt")]);
@@ -2064,7 +2289,7 @@ mod tests {
}
#[test]
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
fn pager_view_positions_selected_chunk_in_upper_third() {
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 1),
@@ -2076,30 +2301,14 @@ mod tests {
);
let area = Rect::new(0, 0, 20, 8);
pv.scroll_offset = 0;
let content_area = pv.content_area(area);
pv.ensure_chunk_visible(/*idx*/ 2, content_area);
pv.position_chunk_at_upper_third(/*idx*/ 2, /*row_offset*/ 0, content_area);
let mut buf = Buffer::empty(area);
pv.render(area, &mut buf);
let rendered = buffer_to_text(&buf, area);
assert!(
rendered.contains("c0"),
"expected chunk top in view: {rendered:?}"
);
assert!(
rendered.contains("c1"),
"expected chunk middle in view: {rendered:?}"
);
assert!(
rendered.contains("c2"),
"expected chunk bottom in view: {rendered:?}"
);
assert_eq!(pv.scroll_offset, 2);
}
#[test]
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
fn pager_view_upper_third_position_clamps_at_start() {
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 2),
@@ -2112,7 +2321,7 @@ mod tests {
let area = Rect::new(0, 0, 20, 3);
pv.scroll_offset = 6;
pv.ensure_chunk_visible(/*idx*/ 0, area);
pv.position_chunk_at_upper_third(/*idx*/ 0, /*row_offset*/ 0, area);
assert_eq!(pv.scroll_offset, 0);
}
@@ -0,0 +1,18 @@
---
source: tui/src/pager_overlay.rs
expression: "buffer_to_text(&buf, area)"
---
Transcript ────────────────────────────────────────────────
before-11
› second
after-0
after-1
after-2
after-3
────────────────────────────────────────────── 2 / 2 · 65% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q ctrl + o ⌥ + r esc/← → enter/← prev
@@ -0,0 +1,15 @@
---
source: tui/src/pager_overlay.rs
expression: "buffer_to_text(&buf, area)"
---
Transcript ────────────────────────────────────────────────
answer-4
answer-5
answer-6
answer-7
› second
────────────────────────────────────────────── 2 / 2 · 73% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q ctrl + o ⌥ + r esc/← → enter/← prev