Skip to content

Commit 0b2be83

Browse files
committed
fix(gui): stabilize daemon terminal redraw and follow behavior
Daemon-backed terminals were still losing or delaying visible frames under\nreal Codex workloads such as /resume, large redraw bursts, and cursor-\nrelative prompt updates. Arbor could blank the terminal while waiting for\na redraw, let stale snapshot rebuilds overwrite newer content, follow the\nwrong part of the buffer, or spam bottom-scroll requests often enough to\nmake redraws feel unstable.\n\nThis change tightens the daemon terminal pipeline end to end. Inline\nwebsocket snapshots now stay alive long enough for /resume-style redraws,\nblank alt-screen clears are held back until real content arrives, stale\nfull snapshots and rebuilds are ignored, and rebuilds are tagged with\nemulator generation so older work cannot clobber newer output. The GUI\nterminal renderer now preserves visible blank rows, paints only the\nvisible slice instead of a giant history canvas, clears the full canvas\nbackground before repaint, and adds focused traces for snapshot, render,\nand follow decisions when ARBOR_TERMINAL_DEBUG=1 is set.\n\nFollow mode was also corrected so active interactive redraws stay pinned\nfor the full resume burst instead of dropping after the short follow lock.\nAt the same time, Arbor avoids reissuing pointless bottom-scroll requests\nwhen the viewport is already stable, which reduces visible flicker without\nletting the terminal drift off the tail. The new tests cover delayed\nresume redraws, blank alt-screen transitions, stale snapshot races,\nvisible-slice layout, whitespace-tail trimming, and longer interactive\nfollow windows.\n\nValidation:\n- just format\n- just lint\n- cargo test -p arbor-gui interactive_follow_window_stays_active_for_longer_resume_redraws -- --nocapture\n- cargo test -p arbor-gui scroll_extent_change_detects_growth_and_ignores_steady_repaints -- --nocapture\n- cargo test -p arbor-gui auto_follow_ -- --nocapture
1 parent 0be0a2b commit 0b2be83

10 files changed

Lines changed: 1173 additions & 111 deletions

File tree

crates/arbor-gui/src/app_init.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ impl ArborWindow {
223223
terminal_scroll_handle: ScrollHandle::new(),
224224
terminal_follow_output_until: None,
225225
last_terminal_scroll_offset_y: None,
226+
last_terminal_scroll_max_offset_y: None,
226227
issue_details_scroll_handle: ScrollHandle::new(),
227228
issue_details_scrollbar_drag_offset: None,
228229
last_terminal_grid_size: None,
@@ -692,6 +693,7 @@ impl ArborWindow {
692693
terminal_scroll_handle: ScrollHandle::new(),
693694
terminal_follow_output_until: None,
694695
last_terminal_scroll_offset_y: None,
696+
last_terminal_scroll_max_offset_y: None,
695697
issue_details_scroll_handle: ScrollHandle::new(),
696698
issue_details_scrollbar_drag_offset: None,
697699
last_terminal_grid_size: None,

crates/arbor-gui/src/center_panel.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,138 @@ impl ArborWindow {
257257
)
258258
},
259259
);
260+
if terminal_snapshot_debug_enabled() {
261+
let scroll_offset_y = self.terminal_scroll_handle.offset().y.to_f64() as f32;
262+
let scroll_bounds_height =
263+
self.terminal_scroll_handle.bounds().size.height.to_f64() as f32;
264+
let scroll_max_offset_y =
265+
self.terminal_scroll_handle.max_offset().height.to_f64() as f32;
266+
let scroll_top_px = (-scroll_offset_y).max(0.);
267+
let viewport_slice_range = terminal_viewport_slice_range(
268+
visible_range.start,
269+
styled_lines.len(),
270+
scroll_top_px,
271+
scroll_bounds_height,
272+
line_height,
273+
);
274+
let viewport_slice = &styled_lines[viewport_slice_range.clone()];
275+
let source_kind = if render_source.is_some() {
276+
"runtime"
277+
} else {
278+
"session"
279+
};
280+
let source_state = render_source
281+
.as_ref()
282+
.map_or(session.state, |source| source.state);
283+
let source_output_chars = render_source.as_ref().map_or_else(
284+
|| session.output.chars().count(),
285+
|source| source.output.chars().count(),
286+
);
287+
let source_styled_line_count = render_source
288+
.as_ref()
289+
.map_or(session.styled_output.len(), |source| {
290+
source.styled_output.len()
291+
});
292+
let source_visible_content = render_source.as_ref().map_or_else(
293+
|| {
294+
terminal_styled_lines_have_visible_content(&session.styled_output)
295+
|| !session.output.is_empty()
296+
},
297+
terminal_render_source_has_visible_content,
298+
);
299+
let source_non_whitespace_content = render_source.as_ref().map_or_else(
300+
|| {
301+
terminal_styled_lines_have_non_whitespace_text(&session.styled_output)
302+
|| session
303+
.output
304+
.chars()
305+
.any(|character| !character.is_whitespace())
306+
},
307+
|source| {
308+
if !source.styled_output.is_empty() {
309+
terminal_styled_lines_have_non_whitespace_text(source.styled_output)
310+
} else {
311+
source
312+
.output
313+
.chars()
314+
.any(|character| !character.is_whitespace())
315+
}
316+
},
317+
);
318+
let rendered_visible_content =
319+
terminal_styled_lines_have_visible_content(&styled_lines);
320+
let rendered_non_whitespace_content =
321+
terminal_styled_lines_have_non_whitespace_text(&styled_lines);
322+
let rendered_last_non_whitespace_line =
323+
terminal_last_non_whitespace_line_index(&styled_lines);
324+
let viewport_visible_content =
325+
terminal_styled_lines_have_visible_content(viewport_slice);
326+
let viewport_non_whitespace_content =
327+
terminal_styled_lines_have_non_whitespace_text(viewport_slice);
328+
let viewport_last_non_whitespace_line =
329+
terminal_last_non_whitespace_line_index(viewport_slice);
330+
let source_last_non_whitespace_line = render_source.as_ref().map_or_else(
331+
|| terminal_last_non_whitespace_line_index(&session.styled_output),
332+
|source| {
333+
if !source.styled_output.is_empty() {
334+
terminal_last_non_whitespace_line_index(source.styled_output)
335+
} else {
336+
lines_for_display(source.output, false)
337+
.iter()
338+
.rposition(|line| {
339+
line.chars().any(|character| !character.is_whitespace())
340+
})
341+
}
342+
},
343+
);
344+
let viewport_first_line_excerpt = viewport_slice
345+
.first()
346+
.map(|line| terminal_styled_line_excerpt(line, 96))
347+
.unwrap_or_default();
348+
let viewport_last_line_excerpt = viewport_slice
349+
.last()
350+
.map(|line| terminal_styled_line_excerpt(line, 96))
351+
.unwrap_or_default();
352+
let viewport_last_non_whitespace_excerpt = viewport_last_non_whitespace_line
353+
.and_then(|index| viewport_slice.get(index))
354+
.map(|line| terminal_styled_line_excerpt(line, 96))
355+
.unwrap_or_default();
356+
tracing::info!(
357+
session_id = session.id,
358+
source_kind,
359+
?source_state,
360+
source_output_chars,
361+
source_styled_line_count,
362+
source_visible_content,
363+
source_non_whitespace_content,
364+
source_last_non_whitespace_line,
365+
source_cursor_line = render_source
366+
.as_ref()
367+
.and_then(|source| source.cursor.map(|cursor| cursor.line))
368+
.or(session.cursor.map(|cursor| cursor.line)),
369+
line_count,
370+
visible_start = visible_range.start,
371+
visible_end = visible_range.end,
372+
rendered_line_count = styled_lines.len(),
373+
rendered_visible_content,
374+
rendered_non_whitespace_content,
375+
rendered_last_non_whitespace_line,
376+
viewport_slice_start = viewport_slice_range.start,
377+
viewport_slice_end = viewport_slice_range.end,
378+
viewport_visible_content,
379+
viewport_non_whitespace_content,
380+
viewport_last_non_whitespace_line,
381+
viewport_absolute_last_non_whitespace_line = viewport_last_non_whitespace_line
382+
.map(|index| visible_range.start + viewport_slice_range.start + index),
383+
viewport_first_line_excerpt = %viewport_first_line_excerpt,
384+
viewport_last_line_excerpt = %viewport_last_line_excerpt,
385+
viewport_last_non_whitespace_excerpt = %viewport_last_non_whitespace_excerpt,
386+
scroll_offset_y = scroll_offset_y as f64,
387+
scroll_bounds_height = scroll_bounds_height as f64,
388+
scroll_max_offset_y = scroll_max_offset_y as f64,
389+
"terminal render trace"
390+
);
391+
}
260392

261393
div()
262394
.h_full()
@@ -306,6 +438,7 @@ impl ArborWindow {
306438
cx.listener(Self::handle_terminal_output_mouse_up),
307439
)
308440
.child(render_terminal_lines(
441+
session.id,
309442
styled_lines,
310443
theme,
311444
cell_width,

crates/arbor-gui/src/constants.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ pub(crate) const TERMINAL_OUTPUT_FOLLOW_LOCK_DURATION: Duration = Duration::from
102102
pub(crate) const ACTIVE_DAEMON_EVENT_COALESCE_INTERVAL: Duration = Duration::from_millis(4);
103103
pub(crate) const INTERACTIVE_TERMINAL_SYNC_INTERVAL: Duration = Duration::from_millis(33);
104104
pub(crate) const INTERACTIVE_TERMINAL_SYNC_WINDOW: Duration = Duration::from_secs(2);
105-
pub(crate) const INTERACTIVE_DAEMON_INLINE_SNAPSHOT_WINDOW: Duration = Duration::from_millis(500);
105+
// Slash commands like `/resume` can take a beat before Codex redraws the
106+
// screen. Keep daemon output on the inline snapshot path long enough for that
107+
// first full frame to arrive instead of falling back to a deferred rebuild.
108+
pub(crate) const INTERACTIVE_DAEMON_INLINE_SNAPSHOT_WINDOW: Duration = Duration::from_secs(2);
106109
// The daemon PTY reader currently emits up to 8 KiB chunks. Keep the inline snapshot
107110
// budget above that so `df`-style bursts stay on the fast path instead of waiting for
108111
// a deferred snapshot rebuild.

0 commit comments

Comments
 (0)