Skip to content

fix(tui): make streamed assistant rendering redraw-safe via an active draft - #632

Open
rian-dolphin wants to merge 2 commits into
huggingface:mainfrom
rian-dolphin:streaming-assistant-draft
Open

fix(tui): make streamed assistant rendering redraw-safe via an active draft#632
rian-dolphin wants to merge 2 commits into
huggingface:mainfrom
rian-dolphin:streaming-assistant-draft

Conversation

@rian-dolphin

@rian-dolphin rian-dolphin commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Problem

The TUI can show streamed thinking text or answer text two times. This occurs when the app rebuilds the transcript during a response. Ctrl+T, slash commands, a terminal resize, or a theme change can start a rebuild.

The old code kept one live turn in three places:

  • The answer text was in TuiState.assistant_buffer.
  • The thinking text was in TuiState.items.
  • The live widget pointers were in TranscriptView.

A rebuild made the live thinking rows into ordinary history rows. It also removed the live widget pointers. The next delta then made a second live widget. Completion removed only one copy. The other copy stayed on the screen. The session file was always correct. Only the TUI display was incorrect.

Solution

This change uses the same ownership model as Pi.

  • TuiState.items now holds only finalized history.
  • One ActiveAssistantDraft object holds the full in-flight turn. It keeps the last cumulative message snapshot and a stream ID.
  • The adapter reads the cumulative MessageUpdateEvent.message as the truth. It does not add deltas together.
  • A pure helper projects the draft into ordered blocks. It keeps the provider block order. It shows one placeholder for each unbroken run of hidden thinking blocks.
  • One _ActiveAssistantRender object owns all live tail widgets. A full redraw makes this owner again from the draft. A redraw can no longer make live rows into rows without an owner.
  • sync_active_assistant() does the incremental updates. The usual path writes only the missing text suffix through MarkdownStream. New blocks mount without a rebuild of the other blocks. A correction or a replacement stream causes a full rebuild of the tail.
  • finish_active_assistant() completes the turn one time only. When the block layout agrees, it binds the live widgets to the canonical items in place. Unrelated history widgets keep their identity.
  • A render generation counter and the stream ID stop stale async work. Stale work cannot mount or register removed widgets.
  • Cancellation and flush keep the full partial turn. This agrees with the aborted message in the session file.

Tests

  • Adapter tests cover the draft lifecycle, corrections, interruption, and replacement streams.
  • App tests cover a redraw, Ctrl+T, a slash command, a theme change, and a resize during a stream.
  • App tests also cover block order, hidden thinking runs, empty final content, abort, cancel, retry, and windowed scrollback.
  • Deterministic race tests hold MarkdownStream.write() and stop() with asyncio.Event. They then start a redraw at an exact await point.

The performance behavior does not change. The usual path writes only a suffix for each delta. It does not parse the full markdown again. It does not remount unrelated history.

Validation

  • uv run pytest tests/test_tui_adapter.py tests/test_tui_app.py passes (432 tests).
  • uv run ruff check . and uv run mypy src pass.
  • The full suite passes. One unrelated test fails on main also, because of local environment tools.

A manual TUI smoke test with slow streaming is still necessary before merge.

Documentation

See dev-notes/tui-active-assistant-draft.md. It explains the draft model, the redraw behavior, and the map to Pi's design.

Example of the problem

If I ran /session or some other command while the model was thinking, it would duplicate the thinking block. I also had cases where the full output block was duplicated as well, but found that harder to reproduce. You can see below that the first thinking block is duplicated. After the fix, I couldn't reproduce that.

Screenshot 2026-08-21 at 17 30 56

Replace the split provisional state (assistant_buffer, thinking rows in
TuiState.items, per-widget liveness pointers) with one explicit
ActiveAssistantDraft holding the cumulative assistant snapshot and a
monotonic stream id. TuiState.items now contains finalized history only,
and focused lifecycle methods (begin/update/finish/discard/interrupt)
own every draft transition.

TranscriptView projects the draft through a pure ordered-block helper
(interleaved thinking/text preserved, one placeholder per contiguous
hidden thinking run, matching Pi) and owns all live tail widgets under
one _ActiveAssistantRender. Full redraws reconstruct that ownership, so
Ctrl+T, slash commands, theme changes, resizes, and other refreshes can
no longer orphan or duplicate streamed blocks. Incremental updates flow
through sync_active_assistant, which writes only missing suffixes via
MarkdownStream, mounts only additively-new blocks, and rebuilds the tail
for corrections or replacement streams. Completion is one idempotent
finish_active_assistant that finalizes and rebinds owned widgets in
place when topology matches; append_item is identity-idempotent and
inserts new rows before the active tail. A render generation counter
plus stream ids make stale async work abort instead of mounting or
registering removed widgets.

Cancellation and flush now retain the whole partial turn consistently
(thinking and text alike), matching the aborted message the session
records. Deterministic race tests gate MarkdownStream write/stop to
interleave redraws at exact await points.
…m PR 631

Add two deterministic scenarios the earlier fix attempt covered: a
thinking toggle processed while finalization is suspended on a gated
MarkdownStream.stop, and an interrupted stream's projected rows
surviving the next turn's completion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant