Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ae7c3eb97 | ||
|
|
a73eb6284f |
@@ -90,6 +90,7 @@ impl StructuredPane {
|
||||
|
||||
let viewport = usize::from(area.height);
|
||||
let max_scroll = lines.len().saturating_sub(viewport);
|
||||
state.scroll = reconcile_scroll(&state.scroll, &state.line_rows, &lines);
|
||||
if state.follow_selection {
|
||||
if let Some(selected) = state.selected.as_deref() {
|
||||
let first = lines
|
||||
@@ -138,6 +139,7 @@ impl StructuredPane {
|
||||
Rect::new(area.x, area.y + offset as u16, area.width, 1),
|
||||
);
|
||||
}
|
||||
state.line_rows = lines.iter().map(|line| line.row_id.clone()).collect();
|
||||
}
|
||||
|
||||
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
|
||||
@@ -214,6 +216,96 @@ fn error_line(text: &str) -> VisualLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate `scroll` — a raw line index into `old_rows` (the previous
|
||||
/// render's row-per-line layout) — into the equivalent index into `lines`
|
||||
/// (this render's freshly rebuilt layout), anchored on row identity rather
|
||||
/// than raw position. A row's wrapped lines above or intersecting the
|
||||
/// viewport growing, shrinking, or moving index therefore shifts `scroll`
|
||||
/// along with it instead of leaving the viewport looking at different
|
||||
/// content. Falls back to the nearest surviving neighbour row when the
|
||||
/// anchor row itself is gone, and to the raw index (clamped) when nothing
|
||||
/// nearby survived either.
|
||||
fn reconcile_scroll(scroll: &usize, old_rows: &[Option<String>], lines: &[VisualLine]) -> usize {
|
||||
if old_rows.is_empty() {
|
||||
return *scroll;
|
||||
}
|
||||
let old_index = (*scroll).min(old_rows.len() - 1);
|
||||
let anchor = old_rows[old_index].clone();
|
||||
match anchor {
|
||||
Some(id) => match find_row_block(lines, &id) {
|
||||
Some((start, len)) => {
|
||||
start + (old_index - block_start(old_rows, old_index)).min(len - 1)
|
||||
}
|
||||
None => fallback_after_removal(old_rows, lines, old_index),
|
||||
},
|
||||
None => {
|
||||
// Untagged tail lines (connecting/dead/error) carry no row identity;
|
||||
// approximate by preserving distance from the end of the transcript.
|
||||
let distance_from_end = old_rows.len() - old_index;
|
||||
lines.len().saturating_sub(distance_from_end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the first line belonging to `id`, and how many lines it spans.
|
||||
/// A row's wrapped lines are always contiguous in `lines` — `build_lines`
|
||||
/// emits them as one run per row — so a single forward scan suffices.
|
||||
fn find_row_block(lines: &[VisualLine], id: &str) -> Option<(usize, usize)> {
|
||||
let start = lines
|
||||
.iter()
|
||||
.position(|line| line.row_id.as_deref() == Some(id))?;
|
||||
let mut end = start;
|
||||
while end + 1 < lines.len() && lines[end + 1].row_id.as_deref() == Some(id) {
|
||||
end += 1;
|
||||
}
|
||||
Some((start, end - start + 1))
|
||||
}
|
||||
|
||||
/// First index of the contiguous run that `index` sits in within `rows`.
|
||||
fn block_start(rows: &[Option<String>], index: usize) -> usize {
|
||||
let mut start = index;
|
||||
while start > 0 && rows[start - 1] == rows[index] {
|
||||
start -= 1;
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
/// The anchor row is gone (removed, or replaced wholesale by a fresh
|
||||
/// snapshot). Search outward from its old position, nearest first, for a
|
||||
/// still-live neighbour row and land on its first line; if none of the old
|
||||
/// layout's rows survived, fall back to the raw old index clamped into the
|
||||
/// new layout.
|
||||
fn fallback_after_removal(
|
||||
old_rows: &[Option<String>],
|
||||
lines: &[VisualLine],
|
||||
old_index: usize,
|
||||
) -> usize {
|
||||
let len = old_rows.len();
|
||||
let mut distance = 1usize;
|
||||
loop {
|
||||
if distance > old_index && old_index + distance >= len {
|
||||
return old_index.min(lines.len().saturating_sub(1));
|
||||
}
|
||||
if distance <= old_index {
|
||||
if let Some((start, _)) = old_rows[old_index - distance]
|
||||
.as_ref()
|
||||
.and_then(|id| find_row_block(lines, id))
|
||||
{
|
||||
return start;
|
||||
}
|
||||
}
|
||||
if old_index + distance < len {
|
||||
if let Some((start, _)) = old_rows[old_index + distance]
|
||||
.as_ref()
|
||||
.and_then(|id| find_row_block(lines, id))
|
||||
{
|
||||
return start;
|
||||
}
|
||||
}
|
||||
distance += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
let mut out = Vec::new();
|
||||
for row in &state.rows {
|
||||
@@ -1272,4 +1364,213 @@ mod tests {
|
||||
assert!(text.contains("adapter exited"), "{text}");
|
||||
assert!(text.contains("request failed"), "{text}");
|
||||
}
|
||||
|
||||
fn test_pane() -> StructuredPane {
|
||||
StructuredPane {
|
||||
handle: StructuredHandle::test_handle(StructuredState::default()),
|
||||
focused: true,
|
||||
mode: Mode::Normal,
|
||||
session_status: SessionStatus::Running,
|
||||
activity_detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered_rows(state: &mut StructuredState, count: usize) {
|
||||
for n in 0..count {
|
||||
state.rows.push(row(
|
||||
&format!("r{n}"),
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":format!("line {n}")}}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn grow_row(state: &mut StructuredState, id: &str) {
|
||||
let index = state.rows.iter().position(|r| r.id == id).unwrap();
|
||||
state.rows[index] = row(
|
||||
id,
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"a very long streaming reply that keeps growing and will wrap across several lines now"}}),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_transcript_lines(
|
||||
pane: &StructuredPane,
|
||||
state: &mut StructuredState,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Vec<String> {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| pane.render_transcript(frame, frame.area(), state))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
(0..height)
|
||||
.map(|y| {
|
||||
(0..width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_growing_above_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
grow_row(&mut state, "r3");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 10"),
|
||||
"a row growing above the viewport must not shift what's on screen: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_a_row_below_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
state.rows.push(row(
|
||||
"r20",
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"line 20"}}),
|
||||
));
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert_eq!(before, after, "growth below the viewport must not move it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
state.rows.retain(|r| r.id != "r3");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 10"),
|
||||
"a row removed above the viewport must not shift what's on screen: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_anchor_row_falls_back_to_the_nearest_surviving_neighbor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
state.rows.retain(|r| r.id != "r10");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 9"),
|
||||
"removing the anchor row should fall back to its nearest surviving neighbor: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patching_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
|
||||
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||
// viewport expanding into a multi-line diff.
|
||||
let index = state.rows.iter().position(|r| r.id == "r5").unwrap();
|
||||
state.rows[index] = row(
|
||||
"r5",
|
||||
TranscriptRowKind::ToolDiff,
|
||||
json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a","newText":"a\nb\nc\nd"}]}),
|
||||
);
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 10"),
|
||||
"a patched row above the viewport must not shift what's on screen: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_width_reconciles_without_panicking() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
|
||||
render_transcript_lines(&pane, &mut state, 1, 3);
|
||||
grow_row(&mut state, "r3");
|
||||
render_transcript_lines(&pane, &mut state, 1, 3);
|
||||
assert!(state.scroll <= state.line_rows.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_selection_without_a_selection_stays_pinned_to_the_live_edge() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = true;
|
||||
state.selected = None;
|
||||
numbered_rows(&mut state, 20);
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before.last().unwrap().contains("line 19"), "{before:?}");
|
||||
|
||||
state.rows.push(row(
|
||||
"r20",
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"line 20"}}),
|
||||
));
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after.last().unwrap().contains("line 20"),
|
||||
"bottom-follow must track the live edge as rows are appended: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_selection_keeps_the_selected_row_visible_when_a_row_above_it_grows() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = true;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.selected = Some("r15".into());
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
before.iter().any(|line| line.contains("line 15")),
|
||||
"{before:?}"
|
||||
);
|
||||
|
||||
grow_row(&mut state, "r3");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after.iter().any(|line| line.contains("line 15")),
|
||||
"the selected row must stay visible while follow_selection is on: {after:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ pub struct StructuredState {
|
||||
pub selected: Option<String>,
|
||||
pub expanded: HashSet<String>,
|
||||
pub scroll: usize,
|
||||
/// Row id per rendered line from the last render pass, parallel to that
|
||||
/// pass's `lines`. Lets the next render translate `scroll` — a raw index
|
||||
/// into that now-stale layout — back into a row identity before mapping
|
||||
/// it onto the freshly rebuilt layout, so growth/shrinkage above the
|
||||
/// viewport shifts indices without shifting what's on screen.
|
||||
pub(crate) line_rows: Vec<Option<String>>,
|
||||
pub follow_selection: bool,
|
||||
pub composer: TextInput,
|
||||
pub error: Option<String>,
|
||||
@@ -42,6 +48,7 @@ impl Default for StructuredState {
|
||||
selected: None,
|
||||
expanded: HashSet::new(),
|
||||
scroll: 0,
|
||||
line_rows: Vec::new(),
|
||||
follow_selection: true,
|
||||
composer: TextInput::default(),
|
||||
error: None,
|
||||
|
||||
Reference in New Issue
Block a user