Skip to content

Commit 787451e

Browse files
committed
TUI: reconcile streaming block heights
1 parent b177932 commit 787451e

3 files changed

Lines changed: 146 additions & 22 deletions

File tree

crates/warp_tui/src/agent_block.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1126,7 +1126,6 @@ impl TuiAIBlock {
11261126
/// Whether the cached height is stale at `width`.
11271127
pub(super) fn needs_height_measurement(&self, width: u16, app: &AppContext) -> bool {
11281128
self.last_measured_width.get() != Some(width)
1129-
|| self.block_model.status(app).is_streaming()
11301129
|| self.action_views.values().any(|view| match view {
11311130
TuiToolCallView::AskQuestion(_)
11321131
| TuiToolCallView::FileEdits(_)
@@ -1139,6 +1138,10 @@ impl TuiAIBlock {
11391138
})
11401139
}
11411140

1141+
/// Whether this block's response is still actively streaming.
1142+
pub(super) fn is_streaming(&self, app: &AppContext) -> bool {
1143+
self.block_model.status(app).is_streaming()
1144+
}
11421145
/// Records the width used for the latest height measurement.
11431146
pub(super) fn record_height_measurement(&self, width: u16) {
11441147
self.last_measured_width.set(Some(width));

crates/warp_tui/src/tui_block_list_viewport_source.rs

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use warp::tui_export::{BlockHeight, BlockHeightItem, BlockHeightSummary, BlockId
1414
use warpui::{EntityId, ViewHandle};
1515
use warpui_core::AppContext;
1616
use warpui_core::elements::tui::{
17-
TuiChildView, TuiElement, TuiLayoutContext, TuiRowResize, TuiSelectionSpan, TuiViewportContent,
18-
TuiViewportWindow, TuiViewportedElement, TuiVisibleViewportItem,
17+
TuiChildView, TuiConstraint, TuiElement, TuiLayoutContext, TuiRowResize, TuiSelectionSpan,
18+
TuiSize, TuiViewportContent, TuiViewportWindow, TuiViewportedElement, TuiVisibleViewportItem,
1919
};
2020

2121
use super::agent_block::TuiAIBlock;
@@ -64,6 +64,7 @@ pub(super) struct TuiBlockListViewportSource {
6464
cli_subagent_blocks: CLISubagentBlockRegistry,
6565
handoff_blocks: HandoffBlockRegistry,
6666
height_changes: RefCell<Vec<TuiRowResize>>,
67+
deferred_streaming_heights: RefCell<HashSet<EntityId>>,
6768
}
6869

6970
impl TuiBlockListViewportSource {
@@ -79,6 +80,7 @@ impl TuiBlockListViewportSource {
7980
cli_subagent_blocks: Rc::new(RefCell::new(HashMap::new())),
8081
handoff_blocks: Rc::new(RefCell::new(HashMap::new())),
8182
height_changes: RefCell::new(Vec::new()),
83+
deferred_streaming_heights: RefCell::new(HashSet::new()),
8284
}
8385
}
8486
pub(super) fn new_with_rich_content(
@@ -93,6 +95,7 @@ impl TuiBlockListViewportSource {
9395
cli_subagent_blocks,
9496
handoff_blocks,
9597
height_changes: RefCell::new(Vec::new()),
98+
deferred_streaming_heights: RefCell::new(HashSet::new()),
9699
}
97100
}
98101

@@ -103,10 +106,11 @@ impl TuiBlockListViewportSource {
103106
///
104107
/// A non-dirty band block is re-measured only when its cached height cannot
105108
/// be trusted: its last measurement was at a different width (reflow), it
106-
/// has never been measured (no recorded width), or it is still streaming
107-
/// (its height can grow without a per-update invalidation — e.g. an
108-
/// expanded, still-running shell command). At a stable width with no
109-
/// dynamic height, nothing extra is measured and the cached
109+
/// has never been measured (no recorded width), or it contains dynamic
110+
/// child content such as an expanded, still-running shell command. Agent
111+
/// output updates explicitly dirty their block, so animation-only repaints
112+
/// do not need to measure the entire streaming response again. At a stable
113+
/// width with no dynamic height, the cached
110114
/// `last_laid_out_height` is reused. Off-band blocks keep their cached
111115
/// height until they scroll into the band.
112116
fn agent_heights_to_measure(
@@ -117,16 +121,41 @@ impl TuiBlockListViewportSource {
117121
) -> HashSet<EntityId> {
118122
let mut model = self.model.lock();
119123
let mut view_ids = model.block_list_mut().take_dirty_rich_content_items();
124+
view_ids.extend(self.deferred_streaming_heights.borrow_mut().drain());
120125

121126
let agent_blocks = self.agent_blocks.borrow();
122127
let cli_subagent_blocks = self.cli_subagent_blocks.borrow();
123128
let handoff_blocks = self.handoff_blocks.borrow();
124-
let block_list = model.block_list();
125129
let band_top = window.scroll_top.saturating_sub(OVERHANG_ROWS);
126130
let band_bottom = window
127131
.scroll_top
128132
.saturating_add(usize::from(window.viewport_height))
129133
.saturating_add(OVERHANG_ROWS);
134+
// A streaming tail below a fixed viewport cannot affect the visible
135+
// rows or their absolute top anchor. Consume this update's dirty bit
136+
// without measuring it, but retain the signal in this source so an
137+
// end-clamped or later approaching query can measure it. The final
138+
// non-streaming update is never deferred, so completed height remains
139+
// exact.
140+
let deferred_streaming = view_ids
141+
.iter()
142+
.filter(|view_id| {
143+
model
144+
.block_list()
145+
.rich_content_row_range(**view_id)
146+
.is_some_and(|rows| rows.start >= band_bottom)
147+
&& agent_blocks
148+
.get(view_id)
149+
.is_some_and(|view| view.as_ref(app).is_streaming(app))
150+
})
151+
.copied()
152+
.collect::<HashSet<_>>();
153+
for view_id in &deferred_streaming {
154+
view_ids.remove(view_id);
155+
}
156+
*self.deferred_streaming_heights.borrow_mut() = deferred_streaming;
157+
158+
let block_list = model.block_list();
130159
let mut cursor = block_list
131160
.block_heights()
132161
.cursor::<BlockHeight, BlockHeightSummary>();
@@ -180,19 +209,56 @@ impl TuiBlockListViewportSource {
180209
view_ids
181210
.into_iter()
182211
.filter_map(|view_id| {
183-
let height = if let Some(view) = agent_blocks.get(&view_id) {
184-
let view = view.as_ref(app);
185-
let height = view.desired_height(width, ctx, app);
212+
let height = if let Some(view_handle) = agent_blocks.get(&view_id) {
213+
let view = view_handle.as_ref(app);
214+
let height = if ctx.rendered_views.contains_key(&view_id) {
215+
usize::from(
216+
TuiChildView::new(view_handle)
217+
.layout(
218+
TuiConstraint::loose(TuiSize::new(width, u16::MAX)),
219+
ctx,
220+
app,
221+
)
222+
.height,
223+
)
224+
} else {
225+
view.desired_height(width, ctx, app)
226+
};
186227
view.record_height_measurement(width);
187228
height
188-
} else if let Some(view) = cli_subagent_blocks.get(&view_id) {
189-
let view = view.as_ref(app);
190-
let height = view.desired_height(width, ctx, app);
229+
} else if let Some(view_handle) = cli_subagent_blocks.get(&view_id) {
230+
let view = view_handle.as_ref(app);
231+
let height = if ctx.rendered_views.contains_key(&view_id) {
232+
usize::from(
233+
TuiChildView::new(view_handle)
234+
.layout(
235+
TuiConstraint::loose(TuiSize::new(width, u16::MAX)),
236+
ctx,
237+
app,
238+
)
239+
.height,
240+
)
241+
} else {
242+
view.desired_height(width, ctx, app)
243+
};
191244
view.record_height_measurement(width);
192245
height
193246
} else {
194-
let view = handoff_blocks.get(&view_id)?.as_ref(app);
195-
let height = view.desired_height(width, ctx, app);
247+
let view_handle = handoff_blocks.get(&view_id)?;
248+
let view = view_handle.as_ref(app);
249+
let height = if ctx.rendered_views.contains_key(&view_id) {
250+
usize::from(
251+
TuiChildView::new(view_handle)
252+
.layout(
253+
TuiConstraint::loose(TuiSize::new(width, u16::MAX)),
254+
ctx,
255+
app,
256+
)
257+
.height,
258+
)
259+
} else {
260+
view.desired_height(width, ctx, app)
261+
};
196262
view.record_height_measurement(width);
197263
height
198264
};

crates/warp_tui/src/tui_block_list_viewport_source_tests.rs

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -363,24 +363,79 @@ fn tui_transcript_scroll_reuses_cached_heights_at_stable_width() {
363363
}
364364

365365
#[test]
366-
fn tui_agent_streaming_block_remeasured_at_stable_width() {
366+
fn tui_agent_streaming_block_reuses_height_until_output_is_dirty() {
367367
App::test((), |mut app| async move {
368368
app.add_singleton_model(|_| Appearance::mock());
369-
// A streaming block's height can grow without a per-update
370-
// invalidation, so it must be re-measured at a stable width.
371369
let (source, model, agent_block) = streaming_agent_block_source(&mut app);
372370

373371
request_top_window(&app, &source, 10);
374372
source.take_selection_row_resizes();
375-
376-
// Seed a wrong height at the same width without dirtying; the streaming
377-
// block is still re-measured, correcting it.
373+
// Animation-only frames reuse the cached height. Output updates mark
374+
// the rich block dirty through `LayoutInvalidated`, which is the
375+
// signal that its streaming text may have changed height.
378376
seed_clean_height(&app, &model, &agent_block, 1234.0, 80);
379377
request_top_window(&app, &source, 10);
378+
assert_eq!(rich_content_height(&model, agent_block.id()), Some(1234.0));
379+
380+
model
381+
.lock()
382+
.block_list_mut()
383+
.mark_rich_content_dirty(agent_block.id());
384+
request_top_window(&app, &source, 10);
380385
assert_ne!(rich_content_height(&model, agent_block.id()), Some(1234.0));
381386
});
382387
}
383388

389+
#[test]
390+
fn tui_agent_offscreen_dirty_streaming_height_is_deferred_until_band() {
391+
App::test((), |mut app| async move {
392+
app.add_singleton_model(|_| Appearance::mock());
393+
let (source, model, agent_block) = seeded_agent_block_source_impl(&mut app, 30, 7.0, true);
394+
model
395+
.lock()
396+
.block_list_mut()
397+
.mark_rich_content_dirty(agent_block.id());
398+
399+
request_top_window(&app, &source, 1);
400+
401+
assert_eq!(rich_content_height(&model, agent_block.id()), Some(7.0));
402+
assert!(
403+
model
404+
.lock()
405+
.block_list_mut()
406+
.take_dirty_rich_content_items()
407+
.is_empty()
408+
);
409+
// The deferred signal survives for the end-clamped/approaching query
410+
// in the same retained source even though the canonical dirty set was
411+
// consumed by the first stale-window query.
412+
413+
app.read(|app| {
414+
let mut rendered_views = EntityIdMap::default();
415+
let mut ctx = TuiLayoutContext {
416+
rendered_views: &mut rendered_views,
417+
};
418+
source.visible_items(
419+
TuiViewportWindow {
420+
scroll_top: 30,
421+
viewport_height: 10,
422+
},
423+
80,
424+
&mut ctx,
425+
app,
426+
);
427+
});
428+
429+
assert_ne!(rich_content_height(&model, agent_block.id()), Some(7.0));
430+
assert!(
431+
model
432+
.lock()
433+
.block_list_mut()
434+
.take_dirty_rich_content_items()
435+
.is_empty()
436+
);
437+
});
438+
}
384439
#[test]
385440
fn completed_markdown_output_update_refreshes_cached_scroll_extent() {
386441
App::test((), |mut app| async move {

0 commit comments

Comments
 (0)