fix(tui): replay history during resize reflow

This commit is contained in:
Felipe Coury
2026-05-09 12:29:18 -03:00
parent ba8a975898
commit 2de669ee44
3 changed files with 183 additions and 59 deletions
+3 -22
View File
@@ -223,20 +223,6 @@ impl App {
crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow)
}
fn clear_terminal_for_resize_replay(&mut self, tui: &mut tui::Tui) -> Result<()> {
if tui.is_alt_screen_active() {
tui.terminal.clear_visible_screen()?;
} else {
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
}
let mut area = tui.terminal.viewport_area;
if area.y > 0 {
area.y = 0;
tui.terminal.set_viewport_area(area);
}
Ok(())
}
/// Finish stream consolidation by repairing any resize work that happened during streaming.
///
/// This is called after agent-message stream cells have either been replaced by an
@@ -312,7 +298,7 @@ impl App {
}
// Reflow immediately on the draw that observes the resize. Leaving a debounced
// gap lets the terminal emulator's native scrollback reflow show stale rows under
// Codex's next table repaint, especially in xterm.js-backed split panes.
// Codex's next table repaint.
self.transcript_reflow.schedule_immediate();
frame_requester.schedule_frame();
} else if !self.terminal_resize_reflow_enabled() && width.changed {
@@ -417,6 +403,7 @@ impl App {
if self.transcript_cells.is_empty() {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
tui.clear_pending_history_lines();
tui.replay_history_lines_after_resize(Vec::new());
self.reset_history_emission_state();
return Ok(width);
}
@@ -426,15 +413,9 @@ impl App {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
tui.clear_pending_history_lines();
self.clear_terminal_for_resize_replay(tui)?;
self.deferred_history_lines.clear();
if !reflowed_lines.is_empty() {
tui.insert_history_lines_with_wrap_policy(
reflowed_lines,
self.history_line_wrap_policy(),
);
}
tui.replay_history_lines_after_resize(reflowed_lines);
Ok(width)
}
+140 -33
View File
@@ -116,40 +116,8 @@ where
let last_cursor_pos = terminal.last_known_cursor_pos;
let writer = terminal.backend_mut();
// Pre-wrap lines for terminal scrollback. Three paths:
//
// - URL-only-ish lines are kept intact (no hard newlines inserted) so that
// terminal emulators can match them as clickable links. The
// terminal will character-wrap these lines at the viewport
// boundary.
// - Mixed lines (URL + non-URL prose) are adaptively wrapped so
// non-URL text still wraps naturally while URL tokens remain
// unsplit.
// - Non-URL lines also flow through adaptive wrapping; behavior is
// equivalent to standard wrapping when no URL is present.
let wrap_width = area.width.max(1) as usize;
let mut wrapped = Vec::new();
let mut wrapped_rows = 0usize;
for line in &lines {
let line_wrapped = match wrap_policy {
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
HistoryLineWrapPolicy::PreWrap
if is_preformatted_box_table_line(line)
|| (line_contains_url_like(line)
&& !line_has_mixed_url_and_non_url_tokens(line)) =>
{
vec![line.clone()]
}
HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)),
};
wrapped_rows += line_wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
.sum::<usize>();
wrapped.extend(line_wrapped);
}
let wrapped_lines = wrapped_rows as u16;
let (wrapped, wrapped_lines) = wrap_history_lines(&lines, wrap_width, wrap_policy);
match mode {
InsertHistoryMode::Zellij => {
@@ -248,6 +216,84 @@ where
Ok(())
}
/// Replay history rows after the caller has cleared terminal scrollback/screen.
///
/// Unlike [`insert_history_lines_with_mode`], this does not use scroll regions or
/// reverse-index insertion. Resize reflow already owns replacing the visible
/// transcript from source-backed cells, so this path writes the rebuilt rows
/// from the top of the terminal and leaves the inline viewport directly after
/// the visible history tail.
pub(crate) fn replay_history_lines_after_clear<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line<'static>>,
) -> io::Result<()>
where
B: Backend + Write,
{
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
let mut area = terminal.viewport_area;
area.width = screen_size.width;
area.y = 0;
let wrap_width = area.width.max(1) as usize;
let (wrapped, wrapped_rows) =
wrap_history_lines(&lines, wrap_width, HistoryLineWrapPolicy::PreWrap);
let max_visible_history_rows = screen_size.height.saturating_sub(area.height);
area.y = wrapped_rows.min(max_visible_history_rows);
terminal.set_viewport_area(area);
if wrapped_rows == 0 {
return Ok(());
}
let writer = terminal.backend_mut();
queue!(writer, MoveTo(/*x*/ 0, /*y*/ 0))?;
for (index, line) in wrapped.iter().enumerate() {
if index > 0 {
queue!(writer, Print("\r\n"))?;
}
write_history_line(writer, line, wrap_width)?;
}
for _ in 0..area.height {
queue!(writer, Print("\r\n"), Clear(ClearType::UntilNewLine))?;
}
let _ = writer;
terminal.note_history_rows_inserted(wrapped_rows);
Ok(())
}
fn wrap_history_lines<'a>(
lines: &'a [Line<'a>],
wrap_width: usize,
wrap_policy: HistoryLineWrapPolicy,
) -> (Vec<Line<'a>>, u16) {
let mut wrapped = Vec::new();
let mut wrapped_rows = 0usize;
for line in lines {
let line_wrapped = match wrap_policy {
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
HistoryLineWrapPolicy::PreWrap
if is_preformatted_box_table_line(line)
|| (line_contains_url_like(line)
&& !line_has_mixed_url_and_non_url_tokens(line)) =>
{
vec![line.clone()]
}
HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)),
};
wrapped_rows += line_wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
.sum::<usize>();
wrapped.extend(line_wrapped);
}
(wrapped, wrapped_rows as u16)
}
fn is_preformatted_box_table_line(line: &Line<'_>) -> bool {
let text: String = line
.spans
@@ -924,4 +970,65 @@ mod tests {
assert_eq!(term.viewport_area, Rect::new(0, 5, width, 2));
assert_eq!(term.visible_history_rows(), 1);
}
#[test]
fn vt100_resize_replay_replaces_cleared_history_without_incremental_insert() {
let width: u16 = 36;
let height: u16 = 14;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 0, width, /*height*/ 3,
));
insert_history_lines(&mut term, vec![Line::from("stale row that must disappear")])
.expect("insert stale history");
term.clear_scrollback_and_visible_screen_ansi()
.expect("clear before replay");
let lines = vec![
Line::from("┌─────────┬────────────────────┐"),
Line::from("│ Label │ Alpha │"),
Line::from("│ Content │ first value │"),
Line::from("├─────────┼────────────────────┤"),
Line::from("│ Label │ Beta │"),
Line::from("│ Content │ second value │"),
Line::from("└─────────┴────────────────────┘"),
];
replay_history_lines_after_clear(&mut term, lines).expect("replay history");
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
assert_eq!(term.viewport_area, Rect::new(0, 7, width, 3));
assert_eq!(term.visible_history_rows(), 7);
assert!(rows[0].contains("┌─────────┬────────────────────┐"));
assert!(rows[6].contains("└─────────┴────────────────────┘"));
assert!(
!rows.iter().any(|row| row.contains("stale row")),
"stale history survived resize replay: {rows:?}"
);
}
#[test]
fn vt100_resize_replay_keeps_visible_tail_above_viewport_when_history_overflows() {
let width: u16 = 20;
let height: u16 = 8;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 0, width, /*height*/ 2,
));
let lines = (1..=10)
.map(|index| Line::from(format!("history row {index:02}")))
.collect();
replay_history_lines_after_clear(&mut term, lines).expect("replay overflowing history");
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
assert_eq!(term.viewport_area, Rect::new(0, 6, width, 2));
assert_eq!(term.visible_history_rows(), 6);
assert!(rows[0].contains("history row 05"), "rows: {rows:?}");
assert!(rows[5].contains("history row 10"), "rows: {rows:?}");
assert!(rows[6].trim().is_empty(), "rows: {rows:?}");
assert!(rows[7].trim().is_empty(), "rows: {rows:?}");
}
}
+40 -4
View File
@@ -420,6 +420,7 @@ pub struct Tui {
event_broker: Arc<EventBroker>,
pub(crate) terminal: Terminal,
pending_history_lines: Vec<PendingHistoryLines>,
pending_resize_replay_lines: Option<Vec<Line<'static>>>,
alt_saved_viewport: Option<ratatui::layout::Rect>,
#[cfg(unix)]
suspend_context: SuspendContext,
@@ -475,6 +476,7 @@ impl Tui {
event_broker: Arc::new(EventBroker::new()),
terminal,
pending_history_lines: vec![],
pending_resize_replay_lines: None,
alt_saved_viewport: None,
#[cfg(unix)]
suspend_context: SuspendContext::new(),
@@ -671,6 +673,12 @@ impl Tui {
self.frame_requester().schedule_frame();
}
pub(crate) fn replay_history_lines_after_resize(&mut self, lines: Vec<Line<'static>>) {
self.pending_history_lines.clear();
self.pending_resize_replay_lines = Some(lines);
self.frame_requester().schedule_frame();
}
pub fn clear_pending_history_lines(&mut self) {
self.pending_history_lines.clear();
}
@@ -812,6 +820,25 @@ impl Tui {
Ok(is_zellij)
}
fn flush_pending_resize_replay(
terminal: &mut Terminal,
pending_resize_replay_lines: &mut Option<Vec<Line<'static>>>,
alt_screen_active: bool,
) -> Result<bool> {
let Some(lines) = pending_resize_replay_lines.take() else {
return Ok(false);
};
if alt_screen_active {
terminal.clear_visible_screen()?;
} else {
terminal.clear_scrollback_and_visible_screen_ansi()?;
}
crate::insert_history::replay_history_lines_after_clear(terminal, lines)?;
Ok(true)
}
pub fn draw(
&mut self,
height: u16,
@@ -898,12 +925,21 @@ impl Tui {
let terminal = &mut self.terminal;
let mut needs_full_repaint =
Self::update_inline_viewport_for_resize_reflow(terminal, height, self.is_zellij)?;
let flushed_history = Self::flush_pending_history_lines(
let replayed_history = Self::flush_pending_resize_replay(
terminal,
&mut self.pending_history_lines,
self.is_zellij,
&mut self.pending_resize_replay_lines,
self.alt_screen_active.load(Ordering::Relaxed),
)?;
needs_full_repaint |= flushed_history;
if replayed_history {
self.pending_history_lines.clear();
} else {
needs_full_repaint |= Self::flush_pending_history_lines(
terminal,
&mut self.pending_history_lines,
self.is_zellij,
)?;
}
needs_full_repaint |= replayed_history;
if needs_full_repaint {
terminal.invalidate_viewport();