fix(tui): prevent duplicate table scrollback

Defer live table tails during stream consolidation so their provisional
render is not inserted before the canonical markdown cell is rebuilt.

Use the stronger combined scrollback and visible-screen clear path during
transcript reflow so tmux does not preserve stale viewport table output.
This commit is contained in:
Felipe Coury
2026-04-06 15:08:24 -03:00
parent 9c7296d38a
commit 367370b25e
4 changed files with 208 additions and 15 deletions
+65 -12
View File
@@ -2,6 +2,7 @@ use crate::app_backtrack::BacktrackState;
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use crate::app_event::AppEvent;
use crate::app_event::ConsolidationScrollbackReflow;
use crate::app_event::ExitMode;
use crate::app_event::FeedbackCategory;
use crate::app_event::RealtimeAudioDeviceKind;
@@ -3350,6 +3351,11 @@ impl App {
self.reflow_ran_during_stream = false;
}
fn schedule_immediate_resize_reflow(&mut self, tui: &mut tui::Tui) {
self.resize_reflow_pending_until = Some(Instant::now());
tui.frame_requester().schedule_frame();
}
fn handle_draw_size_change(
&mut self,
size: ratatui::layout::Size,
@@ -3417,25 +3423,38 @@ impl App {
}
self.resize_reflow_pending_until = None;
// Drop any queued pre-resize inserts before rebuilding at the new width.
// Track that a reflow happened during an active stream or while trailing
// unconsolidated AgentMessageCells are still pending consolidation so
// ConsolidateAgentMessage can schedule a follow-up reflow.
let reflow_ran_during_stream =
!self.transcript_cells.is_empty() && self.should_mark_reflow_as_stream_time();
self.reflow_transcript_now(tui)?;
if reflow_ran_during_stream {
self.reflow_ran_during_stream = true;
}
Ok(())
}
fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<()> {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
tui.clear_pending_history_lines();
if self.transcript_cells.is_empty() {
self.reset_history_emission_state();
return Ok(());
}
tui.terminal.clear_scrollback()?;
tui.terminal.clear()?;
if tui.is_alt_screen_active() {
tui.terminal.clear_visible_screen()?;
} else {
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
}
self.reset_history_emission_state();
// Track that a reflow happened during an active stream or while trailing
// unconsolidated AgentMessageCells are still pending consolidation so
// ConsolidateAgentMessage can schedule a follow-up reflow.
if self.should_mark_reflow_as_stream_time() {
self.reflow_ran_during_stream = true;
}
let width = tui.terminal.size()?.width;
// Iterate by index to avoid cloning the Vec and bumping Arc refcounts.
for i in 0..self.transcript_cells.len() {
@@ -3446,6 +3465,27 @@ impl App {
Ok(())
}
fn finish_agent_message_consolidation(
&mut self,
tui: &mut tui::Tui,
scrollback_reflow: ConsolidationScrollbackReflow,
) -> Result<()> {
match scrollback_reflow {
ConsolidationScrollbackReflow::IfResizeReflowRan => {
self.maybe_finish_stream_reflow(tui);
}
ConsolidationScrollbackReflow::Required => {
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui)?;
if self.resize_reflow_pending_until.is_none() {
self.reflow_ran_during_stream = false;
}
}
}
Ok(())
}
fn should_mark_reflow_as_stream_time(&self) -> bool {
self.chat_widget.has_active_agent_stream()
|| self.chat_widget.has_active_plan_stream()
@@ -4425,7 +4465,20 @@ impl App {
tui.terminal.last_known_screen_size.width,
);
}
AppEvent::ConsolidateAgentMessage { source, cwd } => {
AppEvent::ConsolidateAgentMessage {
source,
cwd,
scrollback_reflow,
deferred_history_cell,
} => {
if let Some(cell) = deferred_history_cell {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
}
self.transcript_cells.push(cell);
}
// Walk backward to find the contiguous run of streaming AgentMessageCells that
// belong to the just-finalized stream
let end = self.transcript_cells.len();
@@ -4450,7 +4503,7 @@ impl App {
tui.frame_requester().schedule_frame();
}
self.maybe_finish_stream_reflow(tui);
self.finish_agent_message_consolidation(tui, scrollback_reflow)?;
} else {
tracing::debug!(
"ConsolidateAgentMessage: no cells to consolidate(start={start}, end={end})",
+12 -1
View File
@@ -62,6 +62,12 @@ impl RealtimeAudioDeviceKind {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConsolidationScrollbackReflow {
IfResizeReflowRan,
Required,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
@@ -274,10 +280,15 @@ pub(crate) enum AppEvent {
/// finalization. The `App` handler walks backward through `transcript_cells`
/// to find the `AgentMessageCell` run and splices in the consolidated cell.
/// The `cwd` keeps local file-link display stable across the final re-render.
/// If `reflow_ran_during_stream` is set, a follow-up reflow is scheduled.
/// `scrollback_reflow` lets table-tail finalization force the already-emitted
/// terminal scrollback to be rebuilt from the consolidated source-backed cell.
/// `deferred_history_cell` lets callers add the final stream tail to the
/// transcript without first writing its provisional render to scrollback.
ConsolidateAgentMessage {
source: String,
cwd: PathBuf,
scrollback_reflow: ConsolidationScrollbackReflow,
deferred_history_cell: Option<Box<dyn HistoryCell>>,
},
/// Replace the contiguous run of streaming `ProposedPlanStreamCell`s at the
+25 -2
View File
@@ -1737,10 +1737,29 @@ impl ChatWidget {
fn flush_answer_stream_with_separator(&mut self) {
let had_stream_controller = self.stream_controller.is_some();
if let Some(mut controller) = self.stream_controller.take() {
let scrollback_reflow = if controller.has_live_tail() {
crate::app_event::ConsolidationScrollbackReflow::Required
} else {
crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan
};
self.clear_active_stream_tail();
let (cell, source) = controller.finalize();
if let Some(cell) = cell {
self.add_boxed_history(cell);
let deferred_history_cell =
if scrollback_reflow == crate::app_event::ConsolidationScrollbackReflow::Required {
cell
} else {
if let Some(cell) = cell {
self.add_boxed_history(cell);
}
None
};
if let Some(cell) = deferred_history_cell.as_ref() {
debug_assert!(
cell.as_any()
.downcast_ref::<history_cell::AgentMessageCell>()
.is_some(),
"only agent message stream tails should be deferred for consolidation",
);
}
// Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell
// that can re-render from source on resize.
@@ -1748,7 +1767,11 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::ConsolidateAgentMessage {
source,
cwd: self.config.cwd.to_path_buf(),
scrollback_reflow,
deferred_history_cell,
});
} else if let Some(cell) = deferred_history_cell {
self.add_boxed_history(cell);
}
}
self.adaptive_chunking.reset();
@@ -265,6 +265,112 @@ async fn flush_answer_stream_does_not_stop_animation_while_plan_stream_is_active
);
}
#[tokio::test]
async fn flush_answer_stream_requests_scrollback_reflow_for_live_table_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller =
crate::streaming::controller::StreamController::new(Some(80), cwd.as_path(), false);
controller.push("| Name | Notes |\n");
controller.push("| --- | --- |\n");
controller.push("| alpha | FINAL_DUPLICATE_SENTINEL |\n");
assert!(
controller.has_live_tail(),
"expected table holdback to leave a live tail for this regression",
);
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::Required
);
assert!(
deferred_history_cell.is_some(),
"live table tail should be staged for consolidation without provisional insert",
);
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
!saw_insert_history,
"live table tail should not be inserted before canonical reflow"
);
}
#[tokio::test]
async fn flush_answer_stream_keeps_default_reflow_for_plain_text_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller =
crate::streaming::controller::StreamController::new(Some(80), cwd.as_path(), false);
assert!(controller.push("plain response line\n"));
assert!(
!controller.has_live_tail(),
"plain completed text should not force scrollback reflow",
);
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan
);
assert!(
deferred_history_cell.is_none(),
"plain text should keep the normal provisional insert path",
);
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
saw_insert_history,
"plain text should still insert history before consolidation"
);
}
#[tokio::test]
async fn flush_answer_stream_does_not_stop_animation_while_plan_table_stream_is_active() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;