mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui/history): keep final scrollback line visible
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user