Compare commits

...
Author SHA1 Message Date
Miguel Palhas 6ae7c3eb97 fix(maestro): anchor structured TUI viewport to row identity
follow_selection=false stored scroll as a raw rendered-line offset into
lines rebuilt fresh every frame. A ProjectionUpdate::Patch growing a
streaming row above the viewport shifted every line below it, so the
raw offset kept pointing at a numerically-same but visually different
line and the viewport drifted even though the operator had scrolled
away deliberately.

Cache each frame's row-id-per-line layout and, before applying scroll
next frame, translate the old raw offset into (row id, intra-row line)
against that cache, then relocate it in the freshly rebuilt lines. Rows
above or intersecting the viewport growing, shrinking, or being
patched now shift the offset instead of the visible content. Falls
back to the nearest surviving neighbor row when the anchor row itself
is removed, and to the clamped raw offset when nothing nearby
survived. follow_selection and bottom-follow are unaffected — they
already recompute scroll from the live selection/tail each frame.
2026-09-05 12:39:21 +01:00
Miguel Palhas a73eb6284f Merge issue #197: remove structured lock inversion 2026-09-05 12:22:55 +01:00
2 changed files with 308 additions and 0 deletions
+301
View File
@@ -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,