Skip to content

Commit d61b6e2

Browse files
committed
fix(tui): coalesce streaming scrollback commits to reduce flicker
1 parent 20b59c3 commit d61b6e2

2 files changed

Lines changed: 295 additions & 1 deletion

File tree

src/pythinker_code/ui/shell/visualize/_interactive.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@
7979

8080
_STATUS_REFRESH_INTERVAL_S = 0.22
8181
_STATUS_REFRESH_REDUCED_INTERVAL_S = 1.0
82+
# Minimum seconds between status-loop scrollback commits. Each commit triggers a
83+
# run_in_terminal prompt-app teardown (visible pop); coalescing to ~3/s removes
84+
# most per-paragraph flicker. This is a MITIGATION, not the structural fix —
85+
# transition/turn-end drains call the unthrottled path directly, and a preview
86+
# overflow forces an immediate flush (see _INCREMENTAL_COMMIT_FORCE_BLOCKS) so
87+
# content is never stranded or silently clipped.
88+
_INCREMENTAL_COMMIT_MIN_INTERVAL_S = 0.30
89+
# If this many committed blocks pile up before the interval elapses, flush now:
90+
# they would otherwise be cropped out of the bounded live preview (silent
91+
# clipping) while waiting for the next allowed commit.
92+
_INCREMENTAL_COMMIT_FORCE_BLOCKS = 4
8293

8394

8495
class _PromptLiveView(_LiveView):
@@ -131,6 +142,7 @@ def __init__(
131142
self._btw_run_task: asyncio.Task[None] | None = None
132143
self._status_refresh_task: asyncio.Task[None] | None = None
133144
self._pending_scrollback: list[tuple[RenderableType, bool]] = []
145+
self._last_incremental_commit_at: float = 0.0
134146

135147
# -- Helpers -------------------------------------------------------------
136148

@@ -222,7 +234,7 @@ async def _status_refresh_loop(self) -> None:
222234
# has backlog, so reduced-motion / unpaced turns fall straight
223235
# through to the calm status cadence below.
224236
advanced = self.advance_stream_reveal()
225-
emitted = await self._emit_incremental_content_commits()
237+
emitted = await self._maybe_emit_incremental_commits()
226238
await self._flush_pending_scrollback()
227239
needs_animation = self._streaming_needs_animation_frame()
228240
if advanced or emitted or needs_animation:
@@ -248,6 +260,33 @@ async def _status_refresh_loop(self) -> None:
248260
def advance_stream_reveal(self) -> bool:
249261
return super().advance_stream_reveal()
250262

263+
async def _maybe_emit_incremental_commits(self) -> bool:
264+
"""Coalescing wrapper for the 25fps status loop.
265+
266+
Each emit triggers a run_in_terminal teardown; cap them to
267+
``_INCREMENTAL_COMMIT_MIN_INTERVAL_S`` so streaming doesn't pop per
268+
paragraph. When suppressed, committed renderables stay in the block and
269+
keep rendering in the live preview (no vanish gap) — UNLESS they pile up
270+
past ``_INCREMENTAL_COMMIT_FORCE_BLOCKS``, in which case the preview would
271+
crop them (silent clipping), so flush now. Transition and turn-end drains
272+
call ``_emit_incremental_content_commits`` directly and are never
273+
throttled, so nothing is stranded at finalize.
274+
"""
275+
block = self._current_content_block
276+
if block is None or block.is_think:
277+
return False
278+
now = time.monotonic()
279+
overflow = len(block._committed_renderables) >= _INCREMENTAL_COMMIT_FORCE_BLOCKS
280+
within_interval = (
281+
now - self._last_incremental_commit_at < _INCREMENTAL_COMMIT_MIN_INTERVAL_S
282+
)
283+
if within_interval and not overflow:
284+
return False
285+
emitted = await self._emit_incremental_content_commits()
286+
if emitted:
287+
self._last_incremental_commit_at = now
288+
return emitted
289+
251290
async def _emit_incremental_content_commits(self) -> bool:
252291
block = self._current_content_block
253292
if block is None or block.is_think:

tests/ui_and_conv/test_visualize_running_prompt.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,261 @@ async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN0
208208
assert invalidations == ["invalidate"]
209209

210210

211+
@pytest.mark.asyncio
212+
async def test_incremental_commits_throttled_between_ticks(monkeypatch) -> None:
213+
"""Status-loop commits are rate-limited so run_in_terminal teardown is rare."""
214+
from pythinker_code.ui.shell.visualize._blocks import _ContentBlock
215+
from pythinker_code.ui.shell.visualize._interactive import (
216+
_INCREMENTAL_COMMIT_MIN_INTERVAL_S,
217+
)
218+
219+
terminal_handoffs: list[str] = []
220+
221+
class _PromptSession:
222+
def invalidate(self) -> None:
223+
pass
224+
225+
async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
226+
terminal_handoffs.append("run")
227+
func()
228+
229+
monkeypatch.setattr(_interactive_mod, "run_in_terminal", _run_in_terminal)
230+
monkeypatch.setattr(
231+
_live_view_mod,
232+
"emit_scrollback_block",
233+
lambda _console, renderable: None,
234+
)
235+
236+
clock = {"now": 1000.0}
237+
monkeypatch.setattr(_interactive_mod.time, "monotonic", lambda: clock["now"])
238+
239+
view = _PromptLiveView(
240+
StatusUpdate(),
241+
prompt_session=cast(Any, _PromptSession()),
242+
steer=lambda _content: None,
243+
)
244+
245+
def _fresh_block() -> _ContentBlock:
246+
block = _ContentBlock(is_think=False)
247+
block.append("Paragraph one.\n\ntail")
248+
assert block._committed_renderables
249+
return block
250+
251+
# First tick: due immediately (last-commit time starts at 0.0) -> one handoff.
252+
view._current_content_block = _fresh_block()
253+
assert await view._maybe_emit_incremental_commits() is True
254+
255+
# Second tick within the interval: suppressed, no extra handoff, content kept.
256+
view._current_content_block = _fresh_block()
257+
assert await view._maybe_emit_incremental_commits() is False
258+
assert view._current_content_block._committed_renderables # not taken/stranded
259+
260+
# Advance past the interval: emits again.
261+
clock["now"] += _INCREMENTAL_COMMIT_MIN_INTERVAL_S + 0.01
262+
assert await view._maybe_emit_incremental_commits() is True
263+
264+
assert terminal_handoffs == ["run", "run"]
265+
266+
267+
@pytest.mark.asyncio
268+
async def test_transition_drain_bypasses_commit_throttle(monkeypatch) -> None:
269+
"""The direct (unthrottled) emit path always flushes, even within the interval."""
270+
from pythinker_code.ui.shell.visualize._blocks import _ContentBlock
271+
272+
terminal_handoffs: list[str] = []
273+
274+
class _PromptSession:
275+
def invalidate(self) -> None:
276+
pass
277+
278+
async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
279+
terminal_handoffs.append("run")
280+
func()
281+
282+
monkeypatch.setattr(_interactive_mod, "run_in_terminal", _run_in_terminal)
283+
monkeypatch.setattr(
284+
_live_view_mod,
285+
"emit_scrollback_block",
286+
lambda _console, renderable: None,
287+
)
288+
289+
clock = {"now": 1000.0}
290+
monkeypatch.setattr(_interactive_mod.time, "monotonic", lambda: clock["now"])
291+
292+
view = _PromptLiveView(
293+
StatusUpdate(),
294+
prompt_session=cast(Any, _PromptSession()),
295+
steer=lambda _content: None,
296+
)
297+
298+
# Throttled call consumes the budget.
299+
block_a = _ContentBlock(is_think=False)
300+
block_a.append("First.\n\ntail")
301+
view._current_content_block = block_a
302+
assert await view._maybe_emit_incremental_commits() is True
303+
304+
# Within the interval, the DIRECT method still flushes (used by transition/finalize).
305+
block_b = _ContentBlock(is_think=False)
306+
block_b.append("Second.\n\ntail")
307+
view._current_content_block = block_b
308+
assert await view._emit_incremental_content_commits() is True
309+
310+
assert terminal_handoffs == ["run", "run"]
311+
312+
313+
@pytest.mark.asyncio
314+
async def test_many_completed_paragraphs_batch_into_one_terminal_handoff(monkeypatch) -> None:
315+
"""N completed paragraphs in one tick coalesce into a SINGLE teardown, not N."""
316+
from pythinker_code.ui.shell.visualize._blocks import _ContentBlock
317+
318+
terminal_handoffs: list[str] = []
319+
printed: list[object] = []
320+
321+
class _PromptSession:
322+
def invalidate(self) -> None:
323+
pass
324+
325+
async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
326+
terminal_handoffs.append("run")
327+
func()
328+
329+
monkeypatch.setattr(_interactive_mod, "run_in_terminal", _run_in_terminal)
330+
monkeypatch.setattr(
331+
_live_view_mod,
332+
"emit_scrollback_block",
333+
lambda _console, renderable: printed.append(renderable),
334+
)
335+
monkeypatch.setattr(_interactive_mod.time, "monotonic", lambda: 1000.0)
336+
337+
view = _PromptLiveView(
338+
StatusUpdate(),
339+
prompt_session=cast(Any, _PromptSession()),
340+
steer=lambda _content: None,
341+
)
342+
block = _ContentBlock(is_think=False)
343+
for paragraph in ("p1.\n\n", "p2.\n\n", "p3.\n\n"):
344+
block.append(paragraph)
345+
block.append("tail") # three committable paragraphs
346+
assert len(block._committed_renderables) >= 3
347+
view._current_content_block = block
348+
349+
assert await view._maybe_emit_incremental_commits() is True
350+
# One teardown for all three paragraphs; each paragraph printed exactly once.
351+
assert terminal_handoffs == ["run"]
352+
assert len(printed) >= 3
353+
354+
355+
@pytest.mark.asyncio
356+
async def test_suppressed_incremental_commit_remains_visible_in_live_preview(monkeypatch) -> None:
357+
"""A throttled-away commit is NOT taken from the block, so the preview still shows it."""
358+
from pythinker_code.ui.shell.visualize._blocks import _ContentBlock
359+
360+
async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
361+
func()
362+
363+
class _PromptSession:
364+
def invalidate(self) -> None:
365+
pass
366+
367+
monkeypatch.setattr(_interactive_mod, "run_in_terminal", _run_in_terminal)
368+
monkeypatch.setattr(
369+
_live_view_mod, "emit_scrollback_block", lambda _console, renderable: None
370+
)
371+
monkeypatch.setattr(_interactive_mod.time, "monotonic", lambda: 1000.0)
372+
373+
view = _PromptLiveView(
374+
StatusUpdate(),
375+
prompt_session=cast(Any, _PromptSession()),
376+
steer=lambda _content: None,
377+
)
378+
view._last_incremental_commit_at = 1000.0 # interval not yet elapsed -> suppress
379+
380+
block = _ContentBlock(is_think=False)
381+
block.append("Visible paragraph.\n\ntail")
382+
before = list(block._committed_renderables)
383+
assert before # something is committable
384+
view._current_content_block = block
385+
386+
assert await view._maybe_emit_incremental_commits() is False
387+
# Suppressed: renderables retained verbatim, still rendering in the preview.
388+
assert block._committed_renderables == before
389+
390+
391+
@pytest.mark.asyncio
392+
async def test_throttled_committed_blocks_eventually_flush_on_direct_drain(monkeypatch) -> None:
393+
"""After suppression, the direct drain (transition/turn-end) clears the block."""
394+
from pythinker_code.ui.shell.visualize._blocks import _ContentBlock
395+
396+
async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
397+
func()
398+
399+
class _PromptSession:
400+
def invalidate(self) -> None:
401+
pass
402+
403+
monkeypatch.setattr(_interactive_mod, "run_in_terminal", _run_in_terminal)
404+
monkeypatch.setattr(
405+
_live_view_mod, "emit_scrollback_block", lambda _console, renderable: None
406+
)
407+
monkeypatch.setattr(_interactive_mod.time, "monotonic", lambda: 1000.0)
408+
409+
view = _PromptLiveView(
410+
StatusUpdate(),
411+
prompt_session=cast(Any, _PromptSession()),
412+
steer=lambda _content: None,
413+
)
414+
view._last_incremental_commit_at = 1000.0 # suppress the throttled path
415+
416+
block = _ContentBlock(is_think=False)
417+
block.append("Pending.\n\ntail")
418+
view._current_content_block = block
419+
assert await view._maybe_emit_incremental_commits() is False
420+
assert block._committed_renderables # still pending
421+
422+
# Finalize/transition path drains directly and leaves nothing stranded.
423+
assert await view._emit_incremental_content_commits() is True
424+
assert not block._committed_renderables
425+
426+
427+
@pytest.mark.asyncio
428+
async def test_throttled_commits_do_not_duplicate_scrollback(monkeypatch) -> None:
429+
"""A suppressed-then-drained commit prints each renderable exactly once (no dupes)."""
430+
from pythinker_code.ui.shell.visualize._blocks import _ContentBlock
431+
432+
printed: list[object] = []
433+
434+
async def _run_in_terminal(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
435+
func()
436+
437+
class _PromptSession:
438+
def invalidate(self) -> None:
439+
pass
440+
441+
monkeypatch.setattr(_interactive_mod, "run_in_terminal", _run_in_terminal)
442+
monkeypatch.setattr(
443+
_live_view_mod,
444+
"emit_scrollback_block",
445+
lambda _console, renderable: printed.append(id(renderable)),
446+
)
447+
monkeypatch.setattr(_interactive_mod.time, "monotonic", lambda: 1000.0)
448+
449+
view = _PromptLiveView(
450+
StatusUpdate(),
451+
prompt_session=cast(Any, _PromptSession()),
452+
steer=lambda _content: None,
453+
)
454+
view._last_incremental_commit_at = 1000.0 # suppress first
455+
456+
block = _ContentBlock(is_think=False)
457+
block.append("Once.\n\ntail")
458+
view._current_content_block = block
459+
460+
assert await view._maybe_emit_incremental_commits() is False # suppressed, nothing printed
461+
assert await view._emit_incremental_content_commits() is True # direct drain prints once
462+
# No renderable printed twice across the suppressed call + the drain.
463+
assert len(printed) == len(set(printed))
464+
465+
211466
def test_render_pinned_status_tail_returns_spinner_when_turn_active() -> None:
212467
import time as _time
213468

0 commit comments

Comments
 (0)