11"""Reusable TUI widget classes for Mother."""
22
3+ import asyncio
34import re
45from dataclasses import dataclass
56from itertools import pairwise
@@ -835,6 +836,7 @@ def __init__(self, markdown: str = "") -> None:
835836 self ._cursor : int = 0
836837 self ._raw : str = markdown
837838 self ._stream : MarkdownStream | None = None
839+ self ._update_lock : asyncio .Lock = asyncio .Lock ()
838840
839841 @property
840842 def raw_markdown (self ) -> str :
@@ -850,31 +852,61 @@ def stream(self) -> MarkdownStream:
850852
851853 async def append_fragment (self , fragment : str ) -> None :
852854 """Append a streamed markdown fragment without reparsing the full response."""
855+ async with self ._update_lock :
856+ await self ._append_fragment_locked (fragment )
857+
858+ async def _append_fragment_locked (self , fragment : str ) -> None :
859+ """Append a fragment while the markdown update lock is held."""
853860 if not fragment :
854861 return
855862 previous_raw = self ._raw
856863 self ._raw += fragment
857864 if (
858- _ends_inside_fenced_block (previous_raw )
865+ _contains_fenced_block_marker (previous_raw )
866+ or _ends_inside_fenced_block (previous_raw )
859867 or _ends_inside_fenced_block (self ._raw )
860868 or _contains_fenced_block_marker (fragment )
861869 ):
862- # Textual's incremental Markdown.append can lose fenced code contents
863- # or produce stale block layout when streamed fragments contain fences.
864- # Reparse the full response whenever a fence is active or appears.
865- await self .stop_stream ()
870+ # Textual's incremental Markdown.append can lose fenced code contents,
871+ # append following paragraphs into the preceding fence, or produce stale
872+ # block layout when streamed fragments contain fences. Once a response
873+ # has seen a fence, reparse the full response for all later fragments.
874+ await self ._stop_stream_locked ()
866875 await self .update (self ._raw )
867876 return
868877 await self .stream .write (fragment )
869878
870879 async def replace_markdown (self , markdown : str ) -> None :
871880 """Replace the rendered markdown, stopping any active incremental stream first."""
872- await self .stop_stream ()
881+ async with self ._update_lock :
882+ await self ._replace_markdown_locked (markdown )
883+
884+ async def _replace_markdown_locked (self , markdown : str ) -> None :
885+ """Replace markdown while the markdown update lock is held."""
886+ await self ._stop_stream_locked ()
873887 self ._raw = markdown
874888 await self .update (markdown )
875889
890+ async def update_streamed_markdown (self , markdown : str , * , force_replace : bool = False ) -> None :
891+ """Render a streamed full-text snapshot in order, ignoring stale prefix snapshots."""
892+ async with self ._update_lock :
893+ current_text = self ._raw
894+ if not force_replace and current_text .startswith (markdown ) and markdown != current_text :
895+ return
896+ if force_replace :
897+ await self ._replace_markdown_locked (markdown )
898+ elif markdown .startswith (current_text ):
899+ await self ._append_fragment_locked (markdown [len (current_text ) :])
900+ else :
901+ await self ._replace_markdown_locked (markdown )
902+
876903 async def stop_stream (self ) -> None :
877904 """Stop the active incremental markdown stream, flushing any pending fragments."""
905+ async with self ._update_lock :
906+ await self ._stop_stream_locked ()
907+
908+ async def _stop_stream_locked (self ) -> None :
909+ """Stop the active incremental markdown stream while the update lock is held."""
878910 if self ._stream is None :
879911 return
880912 stream = self ._stream
0 commit comments