|
|
|
@@ -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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|