fix(tui/scroll): keep viewport stable during streaming

fixes #527
This commit is contained in:
James Peter
2026-01-24 00:18:28 +10:00
parent d042f7ceeb
commit f198c33f09
3 changed files with 57 additions and 3 deletions
+1 -2
View File
@@ -4485,8 +4485,7 @@ impl ChatWidget<'_> {
}
self.request_redraw();
}
/// If the user is at or near the bottom, keep following new messages.
/// We treat "near" as within 3 rows, matching our scroll step.
/// If the user is at the bottom, keep following new messages.
fn autoscroll_if_near_bottom(&mut self) {
layout_scroll::autoscroll_if_near_bottom(self);
}
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::height_manager::HeightEvent;
use ratatui::layout::Rect;
pub(super) fn autoscroll_if_near_bottom(chat: &mut ChatWidget<'_>) {
if chat.layout.scroll_offset.get() <= 3 {
if chat.layout.scroll_offset.get() == 0 {
let before = chat.layout.scroll_offset.get();
chat.layout.scroll_offset.set(0);
chat.bottom_pane.set_compact_compose(false);
@@ -1206,6 +1206,61 @@ fn scroll_position_stable_when_history_grows_scrolled_up() {
);
}
#[test]
fn scroll_position_stable_during_streaming_when_scrolled_up() {
let mut harness = ChatWidgetHarness::new();
for idx in 0..6 {
harness.push_user_prompt(format!("User prompt #{idx}: streaming scroll"));
harness.push_assistant_markdown(format!(
"Assistant response #{idx} keeps the history tall.\n\nExtra paragraph for idx {idx}."
));
}
let _ = render_chat_widget_to_vt100(&mut harness, 80, 24);
let metrics = harness_layout_metrics(&harness);
assert!(
metrics.last_max_scroll > 0,
"scenario must overflow the history viewport to exercise scrolling"
);
let offset = metrics.last_max_scroll.min(3).max(1);
harness_force_scroll_offset(&mut harness, offset);
let _ = render_chat_widget_to_vt100(&mut harness, 80, 24);
let before = harness_layout_metrics(&harness);
let before_from_top =
before
.last_max_scroll
.saturating_sub(before.scroll_offset.min(before.last_max_scroll));
harness.handle_event(Event {
id: "stream-scroll".into(),
event_seq: 1,
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Streaming update line one.\nStreaming update line two.\n".into(),
}),
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(1),
}),
});
harness.drive_commit_tick();
let _ = render_chat_widget_to_vt100(&mut harness, 80, 24);
let after = harness_layout_metrics(&harness);
let after_from_top =
after
.last_max_scroll
.saturating_sub(after.scroll_offset.min(after.last_max_scroll));
assert_eq!(
before_from_top,
after_from_top,
"streaming output should not move the viewport while scrolled"
);
}
#[test]
fn multiline_final_history_line_visible_at_bottom() {
init_tracing_once();