fix(tui/history): keep final scrollback line visible

This commit is contained in:
James Peter
2025-11-12 15:53:49 +10:00
parent ed0d8b7e00
commit f6f7a75f0a
5 changed files with 229 additions and 15 deletions
+48 -4
View File
@@ -30734,6 +30734,18 @@ impl WidgetRef for &ChatWidget<'_> {
scroll_from_top = scroll_from_top.saturating_sub(overscan_extra);
}
tracing::debug!(
target: "code_tui::scrollback",
total_height,
base_total_height,
viewport = content_area.height,
overscan_extra,
max_scroll,
scroll_offset = clamped_scroll_offset,
initial_scroll_from_top = scroll_from_top,
"scrollback pre-adjust scroll position",
);
// Viewport stabilization: when user is scrolled up (offset > 0) and the
// history viewport height changes due to the input area growing/shrinking,
// adjust the scroll_from_top to keep the top line steady on screen.
@@ -30751,11 +30763,22 @@ impl WidgetRef for &ChatWidget<'_> {
// If our scroll origin landed on a spacer row between cells, nudge it up so
// the viewport starts with real content instead of an empty separator.
let adjusted_scroll_from_top = self
.history_render
.adjust_scroll_to_content(scroll_from_top);
let scroll_pos = if clamped_scroll_offset > 0 {
let adjusted = self
.history_render
.adjust_scroll_to_content(scroll_from_top);
tracing::debug!(
target: "code_tui::scrollback",
adjusted_scroll_from_top = adjusted,
scroll_from_top,
"scrollback adjusted scroll position",
);
adjusted
} else {
scroll_from_top
};
(content_area.y, adjusted_scroll_from_top)
(content_area.y, scroll_pos)
};
// Record current viewport height for the next frame
@@ -31212,6 +31235,27 @@ impl WidgetRef for &ChatWidget<'_> {
// Add spacing only if something was actually rendered for this item.
// Prevent a stray blank when zero-height, and suppress spacing between
// consecutive collapsed reasoning titles so they appear as a tight list.
if idx == request_count.saturating_sub(1) {
let viewport_top = content_area.y;
let viewport_bottom = content_area.y.saturating_add(content_area.height);
tracing::debug!(
target: "code_tui::scrollback",
idx,
request_count,
content_y,
scroll_pos,
viewport_top,
viewport_bottom,
skip_top,
item_height,
available_height,
visible_height,
screen_y,
spacing,
"last visible history cell metrics"
);
}
let mut should_add_spacing = idx < request_count.saturating_sub(1) && visible_height > 0;
if should_add_spacing {
// Special-case: two adjacent collapsed reasoning cells → no spacer.
@@ -231,6 +231,10 @@ impl ChatWidgetHarness {
self.chat.history_render.bottom_spacer_lines_for_test()
}
pub fn history_viewport_height(&self) -> u16 {
self.chat.layout.last_history_viewport_height.get()
}
pub fn pending_bottom_spacer_request(&self) -> Option<u16> {
self.chat
.history_render
+28 -1
View File
@@ -154,8 +154,26 @@ pub fn insert_history_lines_to_writer_above<B, W>(
// Pre-wrap to avoid terminal hard-wrap artifacts
let content_width = screen_size.width.max(1);
let wrapped = word_wrap_lines(&lines, content_width);
let wrapped_count = wrapped.len();
tracing::debug!(
target: "code_tui::scrollback",
screen_h,
reserved_bottom_rows,
reserved,
region_bottom,
wrapped_count,
"scrollback insert sizing"
);
if region_bottom <= 1 {
tracing::debug!(
target: "code_tui::scrollback",
screen_h,
reserved_bottom_rows,
reserved,
"scrollback insert fallback: region bottom collapsed"
);
// Degenerate case (startup or unknown size): fall back to simple
// line-by-line prints that let the terminal naturally scroll. This is
// safe before the first bottom-pane draw and avoids a 1-line scroll
@@ -183,6 +201,16 @@ pub fn insert_history_lines_to_writer_above<B, W>(
queue!(writer, Print("\r\n")).ok();
}
tracing::debug!(
target: "code_tui::scrollback",
screen_h,
reserved_bottom_rows,
reserved,
region_bottom,
wrapped_count,
"scrollback insert complete"
);
queue!(writer, ResetScrollRegion).ok();
if let Some(cursor_pos) = cursor_pos {
queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
@@ -546,4 +574,3 @@ fn slice_line_spans(
spans: acc,
}
}
@@ -41,6 +41,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use once_cell::sync::Lazy;
use std::sync::Once;
use tracing_subscriber::{self, EnvFilter};
use tempfile::TempDir;
fn normalize_output(text: String) -> String {
@@ -56,6 +58,15 @@ fn normalize_output(text: String) -> String {
.pipe(normalize_trailing_whitespace)
}
fn init_tracing_once() {
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.try_init();
});
}
fn normalize_ellipsis(text: String) -> String {
text.replace('…', "...")
}
@@ -1115,6 +1126,81 @@ fn scroll_spacing_remains_when_scrolled_up() {
);
}
#[test]
fn multiline_final_history_line_visible_at_bottom() {
init_tracing_once();
let mut harness = ChatWidgetHarness::new();
for idx in 0..4 {
harness.push_user_prompt(format!("User prompt #{idx}: request status"));
harness.push_assistant_markdown(format!(
"Assistant response #{idx} line one explains the current task.\nLine two adds more detail for idx {idx}.\nLine three keeps the history tall."
));
}
harness.push_user_prompt("Summarize next actions");
harness.push_assistant_markdown(
"Summary line one keeps context flowing.\nSummary line two clarifies blockers.\nSummary line three references the fix.\nFINAL ROW SENTINEL -- ensure this stays visible.",
);
// Initial render computes layout metrics (including max scroll) for the scenario.
let _ = render_chat_widget_to_vt100(&mut harness, 60, 14);
let metrics = harness_layout_metrics(&harness);
assert!(
metrics.last_max_scroll > 0,
"scenario must overflow history viewport to exercise bottom-aligned rendering"
);
// Reset scroll offset to show the most recent content (bottom of history).
harness_force_scroll_offset(&mut harness, 0);
let frame = normalize_output(render_chat_widget_to_vt100(&mut harness, 60, 14));
assert!(
frame.contains("FINAL ROW SENTINEL -- ensure this stays visible."),
"final assistant line disappeared when scrolled to bottom:\n{}",
frame
);
}
fn build_history_for_final_line_case(harness: &mut ChatWidgetHarness, blocks: usize) {
for idx in 0..blocks {
harness.push_user_prompt(format!("User prompt #{idx}: please summarize batch {idx}"));
harness.push_assistant_markdown(format!(
"Assistant response #{idx} line one.\nLine two expands context for block {idx}.\nLine three keeps the transcript tall."
));
}
harness.push_user_prompt("Summarize next actions");
harness.push_assistant_markdown(
"Summary line one keeps context flowing.\nSummary line two clarifies blockers.\nSummary line three references the fix.\nFINAL ROW SENTINEL -- ensure this stays visible.",
);
}
fn assert_final_line_visible(mut harness: ChatWidgetHarness, viewport_height: u16) {
let _ = render_chat_widget_to_vt100(&mut harness, 60, viewport_height);
if harness.history_viewport_height() == 0 {
// Nothing to render; composer consumes all rows. Skip this viewport.
return;
}
harness_force_scroll_offset(&mut harness, 0);
let frame = normalize_output(render_chat_widget_to_vt100(&mut harness, 60, viewport_height));
assert!(
frame.contains("FINAL ROW SENTINEL -- ensure this stays visible."),
"final assistant line disappeared for viewport {viewport_height}:\n{}",
frame
);
}
#[test]
fn final_history_line_visible_across_viewports() {
init_tracing_once();
for viewport in 6..=12 {
let mut harness = ChatWidgetHarness::new();
build_history_for_final_line_case(&mut harness, 6);
assert_final_line_visible(harness, viewport);
}
}
#[test]
fn baseline_multiline_formatting() {
let mut harness = ChatWidgetHarness::new();
+63 -10
View File
@@ -363,7 +363,18 @@ fn trim_transcript_cells_to_nth_user(
return;
}
if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) {
if let Some(mut cut_idx) = nth_user_position(transcript_cells, nth_user_message) {
// Retain the selected user cell and any subsequent non-user cells (assistant answers,
// background notices, etc.) so the replayed transcript still shows the final exchange.
cut_idx = cut_idx.saturating_add(1);
let user_type = TypeId::of::<UserHistoryCell>();
while cut_idx < transcript_cells.len() {
let cell_type = transcript_cells[cut_idx].as_any().type_id();
if cell_type == user_type {
break;
}
cut_idx += 1;
}
transcript_cells.truncate(cut_idx);
}
}
@@ -409,17 +420,24 @@ mod tests {
use std::sync::Arc;
#[test]
fn trim_transcript_for_first_user_drops_user_and_newer_cells() {
fn trim_transcript_for_first_user_keeps_selected_exchange() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(UserHistoryCell {
message: "first user".to_string(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
Arc::new(AgentMessageCell::new(vec![Line::from("assistant after first")], true))
as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second user".to_string(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("assistant after second")], true))
as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 0);
assert!(cells.is_empty());
assert_eq!(cells.len(), 2, "user and answer block should remain");
assert!(cells[0].as_any().is::<UserHistoryCell>());
assert!(cells[1].as_any().downcast_ref::<AgentMessageCell>().is_some());
}
#[test]
@@ -435,19 +453,54 @@ mod tests {
];
trim_transcript_cells_to_nth_user(&mut cells, 0);
assert_eq!(cells.len(), 1);
let agent = cells[0]
assert_eq!(cells.len(), 3);
let intro = cells[0]
.as_any()
.downcast_ref::<AgentMessageCell>()
.expect("agent cell");
let agent_lines = agent.display_lines(u16::MAX);
assert_eq!(agent_lines.len(), 1);
let intro_text: String = agent_lines[0]
.expect("intro agent cell");
let intro_lines = intro.display_lines(u16::MAX);
let intro_text: String = intro_lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(intro_text, "• intro");
assert!(cells[1].as_any().is::<UserHistoryCell>());
assert!(cells[2].as_any().downcast_ref::<AgentMessageCell>().is_some());
}
#[test]
fn trim_transcript_keeps_follow_up_agent_block_until_next_user() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(UserHistoryCell {
message: "alpha".to_string(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![
Line::from("assistant line one"),
Line::from("assistant line two"),
],
true,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "beta".to_string(),
}) as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 0);
assert_eq!(cells.len(), 2);
let agent = cells[1]
.as_any()
.downcast_ref::<AgentMessageCell>()
.expect("agent cell for alpha");
let lines = agent.display_lines(u16::MAX);
assert_eq!(lines.len(), 2, "multi-line agent block should be preserved");
let last_line: String = lines[1]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(last_line.contains("assistant line two"));
}
#[test]