From 91cf9108a405c6a276dd4d8e2fe449dd8d9932ef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 19:55:38 -0400 Subject: [PATCH 01/10] Declare transportable Markdown blocks and acquired native content supply --- src/toad/block_content.py | 104 ++++++++++++++++++++++++-- src/toad/conversation_markdown.py | 6 +- src/toad/markdown_preparation.py | 5 ++ src/toad/widgets/prepared_markdown.py | 52 +++++++------ 4 files changed, 137 insertions(+), 30 deletions(-) diff --git a/src/toad/block_content.py b/src/toad/block_content.py index c758ab1fb..79947ccf6 100644 --- a/src/toad/block_content.py +++ b/src/toad/block_content.py @@ -1,6 +1,8 @@ """Nominal selection capabilities on the actual mounted block widgets.""" from collections.abc import Iterable +from textual.widgets import _markdown as native_markdown + from toad.menus import MenuItem @@ -30,13 +32,105 @@ def collapse_block(self) -> None: class MarkdownBlockContent(BlockContent): - """Textual's Markdown block factory admits these capabilities once.""" + """Declared native blocks share source-copy and menu capabilities.""" def get_clipboard_text(self) -> str: return self.source @classmethod - def declare(cls, external: type) -> type: - # Keep Textual's existing token catalog and concrete rendering classes. - # The mounted class itself owns the capability, without a widget wrapper. - return type(external.__name__, (cls, external), {"__module__": __name__}) + def blocks_for(cls, catalog: dict[str, type]) -> dict[str, type]: + """Extend the original token catalog through declared inheritance. + + The native owner decides which token selects which block. These + declarations add capabilities and stable Python identities; neither + token names nor a transport codec select an implementation here. + """ + native = set(catalog.values()) + implementations = {} + for declaration in cls.__subclasses__(): + for base in declaration.__bases__: + if base in native: + if base in implementations: + raise TypeError(f"Multiple declared block extensions for {base.__name__}") + implementations[base] = declaration + return {token: implementations[block] for token, block in catalog.items()} + + +class MarkdownH1(MarkdownBlockContent, native_markdown.MarkdownH1): + pass + + +class MarkdownH2(MarkdownBlockContent, native_markdown.MarkdownH2): + pass + + +class MarkdownH3(MarkdownBlockContent, native_markdown.MarkdownH3): + pass + + +class MarkdownH4(MarkdownBlockContent, native_markdown.MarkdownH4): + pass + + +class MarkdownH5(MarkdownBlockContent, native_markdown.MarkdownH5): + pass + + +class MarkdownH6(MarkdownBlockContent, native_markdown.MarkdownH6): + pass + + +class MarkdownHorizontalRule(MarkdownBlockContent, native_markdown.MarkdownHorizontalRule): + pass + + +class MarkdownParagraph(MarkdownBlockContent, native_markdown.MarkdownParagraph): + pass + + +class MarkdownBlockQuote(MarkdownBlockContent, native_markdown.MarkdownBlockQuote): + pass + + +class MarkdownBulletList(MarkdownBlockContent, native_markdown.MarkdownBulletList): + pass + + +class MarkdownOrderedList(MarkdownBlockContent, native_markdown.MarkdownOrderedList): + pass + + +class MarkdownOrderedListItem(MarkdownBlockContent, native_markdown.MarkdownOrderedListItem): + pass + + +class MarkdownUnorderedListItem(MarkdownBlockContent, native_markdown.MarkdownUnorderedListItem): + pass + + +class MarkdownTable(MarkdownBlockContent, native_markdown.MarkdownTable): + pass + + +class MarkdownTBody(MarkdownBlockContent, native_markdown.MarkdownTBody): + pass + + +class MarkdownTHead(MarkdownBlockContent, native_markdown.MarkdownTHead): + pass + + +class MarkdownTR(MarkdownBlockContent, native_markdown.MarkdownTR): + pass + + +class MarkdownTH(MarkdownBlockContent, native_markdown.MarkdownTH): + pass + + +class MarkdownTD(MarkdownBlockContent, native_markdown.MarkdownTD): + pass + + +class MarkdownFence(MarkdownBlockContent, native_markdown.MarkdownFence): + pass diff --git a/src/toad/conversation_markdown.py b/src/toad/conversation_markdown.py index 48f82dcd7..a9415c6b9 100644 --- a/src/toad/conversation_markdown.py +++ b/src/toad/conversation_markdown.py @@ -14,11 +14,11 @@ from textual.widgets import Markdown from toad.layout import trim_trailing_margin -from toad.block_content import MarkdownBlockContent +from toad.block_content import MarkdownBlockContent, MarkdownFence from toad.project_link import ProjectLink -class ConversationCodeFence(MarkdownBlockContent, Markdown.BLOCKS["fence"]): +class ConversationCodeFence(MarkdownFence): def get_clipboard_text(self) -> str: return self._content.plain @@ -201,7 +201,7 @@ class ConversationMarkdown(Markdown): """Markdown widget with custom blocks.""" BLOCKS = { - **{name: MarkdownBlockContent.declare(block) for name, block in Markdown.BLOCKS.items()}, + **MarkdownBlockContent.blocks_for(Markdown.BLOCKS), "fence": ConversationCodeFence, } diff --git a/src/toad/markdown_preparation.py b/src/toad/markdown_preparation.py index 8325798b1..049abf0d9 100644 --- a/src/toad/markdown_preparation.py +++ b/src/toad/markdown_preparation.py @@ -125,6 +125,11 @@ def acquire_inline_content(self, tokens: list[Token]) -> "PreparedMarkdown": def inline_content(self, token: Token) -> Content: return token.meta[PreparedMarkdown] + def fence_content(self, code: str, language: str, ansi: bool, dark: bool) -> Content | None: + """The acquired theme/source cohort owns highlighted fence content.""" + prepared = self.fences.get((code, language, ansi, dark)) + return None if prepared is None else prepared.content + def prepare_tokens(tokens: list[Token], ansi: bool, dark: bool) -> PreparedMarkdown: fences: dict[FenceKey, PreparedFence] = {} diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index ed918074d..5e5c64199 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -22,6 +22,7 @@ from toad.conversation_markdown import ConversationCodeFence, ConversationMarkdown, _ThreadLocalPathParser from toad.markdown_preparation import PreparedMarkdown, PreparedMarkdownPart from toad.block_content import MarkdownBlockContent +from toad.layout import trim_trailing_margin from toad.render_tasks import MarkdownRenderTask from toad.widgets.viewport_body import MeasuredViewportBody from toad.widgets.worker_static import WorkerStatic @@ -195,6 +196,21 @@ class PreparedH6(ConversationMarkdown.BLOCKS["h6"], PreparedMarkdownContent): pass +class PreparedCodeLabel(Label, PreparedMarkdownContent): + """Code uses the same native Content worker and publication lifetime.""" + + +class PreparedCodeFence(ConversationCodeFence): + def set_content(self, content: Content) -> None: + self._content = content + label = self.query_one_optional("#code-content", PreparedCodeLabel) + if label is not None: + label.update(content) + + def compose(self) -> ComposeResult: + yield PreparedCodeLabel(self._highlighted_code, id="code-content", expand=True) + + class PreparedConversationMarkdown(MarkdownBlockContent, MeasuredViewportBody, ConversationMarkdown): DEFAULT_CSS = """ PreparedConversationMarkdown.-message-fragment { @@ -210,6 +226,7 @@ class PreparedConversationMarkdown(MarkdownBlockContent, MeasuredViewportBody, C "paragraph_open": PreparedParagraph, "h1": PreparedH1, "h2": PreparedH2, "h3": PreparedH3, "h4": PreparedH4, "h5": PreparedH5, "h6": PreparedH6, + "fence": PreparedCodeFence, "code_block": PreparedCodeFence, } def native_body_ready(self) -> bool: @@ -352,8 +369,19 @@ def acquire(): def _get_prepared_fence(self, code: str, language: str, ansi: bool, dark: bool) -> Content | None: resource = self._prepared_markdown - prepared = None if resource is None else resource.fences.get((code, language, ansi, dark)) - return None if prepared is None else prepared.content + return None if resource is None else resource.fence_content(code, language, ansi, dark) + + def acquire_document_content(self): + """Lend original resolved tokens/content, never a bound widget hook.""" + assert self._prepared_markdown is not None + return self._prepared_markdown.inline_content + + def acquire_document_fences(self): + assert self._prepared_markdown is not None + return self._prepared_markdown.fence_content + + def get_document_process_layout(self): + return trim_trailing_margin def _get_token_content(self, token: Token, *, block: MarkdownBlock) -> Content: # The native document owns this token cohort. Content was acquired @@ -364,23 +392,3 @@ def _get_token_content(self, token: Token, *, block: MarkdownBlock) -> Content: return super()._get_token_content(token, block=block) assert self._prepared_markdown is not None return self._prepared_markdown.inline_content(token) - - def get_block_class(self, block_name: str) -> type[MarkdownBlock]: - if block_name in {"fence", "code_block"}: - return PreparedCodeFence - return super().get_block_class(block_name) - - -class PreparedCodeLabel(Label, PreparedMarkdownContent): - """Code uses the same native Content worker and publication lifetime.""" - - -class PreparedCodeFence(ConversationCodeFence): - def set_content(self, content: Content) -> None: - self._content = content - label = self.query_one_optional("#code-content", PreparedCodeLabel) - if label is not None: - label.update(content) - - def compose(self) -> ComposeResult: - yield PreparedCodeLabel(self._highlighted_code, id="code-content", expand=True) From b9d87941186cce2cef1e71eaecd6cf99b0f391a4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 20:36:51 -0400 Subject: [PATCH 02/10] Prepare conversation document layout and paint on rendering workers Default conversation syntax now uses the original native acquired document producer and typed rendering tasks. Source ranges keep real prefixes and acquire native controls only for interaction. Body resources own intrinsic extent, displayed source membership, width invalidation and retained rows; membership changes rematch original CSS and only equal acquired values admit retained paint. Resize/style preparation borrows original parser/link data rather than reacquiring it. Four changed modules compile; complete source/tests/tools parse: 744 modules, zero omissions. Actual saved ACP source App reached process transport but failed on native speedup Spacing pickling before wheel/reader checks. Native geometry owner is fixing that boundary. Source checkpoint only; no installed gain or completed interaction/reader acceptance claim. --- src/toad/render_tasks.py | 69 +++++++-- src/toad/widgets/prepared_markdown.py | 117 ++++++++++++++-- src/toad/widgets/streaming_markdown.py | 76 ++++++++-- src/toad/widgets/viewport_body.py | 185 ++++++++++++++++++++++++- 4 files changed, 405 insertions(+), 42 deletions(-) diff --git a/src/toad/render_tasks.py b/src/toad/render_tasks.py index fe9b13391..e15fab2e1 100644 --- a/src/toad/render_tasks.py +++ b/src/toad/render_tasks.py @@ -17,6 +17,8 @@ ) if TYPE_CHECKING: + from textual.document._markdown import MarkdownDocument + from textual.document._paint import DocumentPaint from toad.widgets.patch_diff import PreparedPatch from toad.work_preparation import PreparationRuntime, RenderPreparation, WorkKey @@ -58,8 +60,40 @@ def accept_result(self, result: object) -> PreparedPatch: return result +class MarkdownSourcePreparation: + """Shared acquisition/identity; source and partition results stay distinct.""" + + source: str | PreparedMarkdownPart + + def acquire_source(self) -> PreparedMarkdownPart: + return (PreparedMarkdownPart.capture(self.source) if isinstance(self.source, str) + else self.source) + + async def preparation_identity(self, work: RenderPreparation, runtime: PreparationRuntime) -> WorkKey: + if isinstance(self.source, PreparedMarkdownPart): + from toad.work_preparation import WorkKey + + return WorkKey(type(work), (type(self), self.source.revision), work.scope) + return await super().preparation_identity(work, runtime) + + +@dataclass(frozen=True) +class MarkdownSyntaxRenderTask(MarkdownSourcePreparation, ReusableRenderTask[PreparedMarkdownPart]): + """Acquire one complete syntax resource when the owner forbids paging.""" + + source: str | PreparedMarkdownPart + + def execute(self) -> PreparedMarkdownPart: + return self.acquire_source() + + def accept_result(self, result: object) -> PreparedMarkdownPart: + if not isinstance(result, PreparedMarkdownPart): + raise TypeError("Markdown renderer returned an invalid syntax resource") + return result + + @dataclass(frozen=True) -class MarkdownRenderTask(ReusableRenderTask[PreparedMarkdown]): +class MarkdownRenderTask(MarkdownSourcePreparation, ReusableRenderTask[PreparedMarkdown]): """Acquire syntax once, then highlight before independent delivery.""" source: str | PreparedMarkdownPart @@ -76,8 +110,7 @@ async def preparation_identity(self, work: RenderPreparation, runtime: Preparati return await super().preparation_identity(work, runtime) def execute(self) -> PreparedMarkdown: - part = (PreparedMarkdownPart.capture(self.source) - if isinstance(self.source, str) else self.source) + part = self.acquire_source() return prepare_tokens(part.acquire_tokens(), self.ansi, self.dark) def accept_result(self, result: object) -> PreparedMarkdown: @@ -87,23 +120,15 @@ def accept_result(self, result: object) -> PreparedMarkdown: @dataclass(frozen=True) -class MarkdownPartsTask(ReusableRenderTask[tuple[PreparedMarkdownPart, ...]]): +class MarkdownPartsTask(MarkdownSourcePreparation, ReusableRenderTask[tuple[PreparedMarkdownPart, ...]]): """Partition one original message with the shared Markdown block budget.""" source: str | PreparedMarkdownPart - async def preparation_identity(self, work: RenderPreparation, runtime: PreparationRuntime) -> WorkKey: - if isinstance(self.source, PreparedMarkdownPart): - from toad.work_preparation import WorkKey - - return WorkKey(type(work), (type(self), self.source.revision), work.scope) - return await super().preparation_identity(work, runtime) - def execute(self) -> tuple[PreparedMarkdownPart, ...]: from toad.widgets.transcript_fragments import RenderBudget - part = (PreparedMarkdownPart.capture(self.source) - if isinstance(self.source, str) else self.source) + part = self.acquire_source() parts = tuple(RenderBudget().split(part)) for part in parts: part.retained_bytes @@ -115,6 +140,24 @@ def accept_result(self, result: object) -> tuple[PreparedMarkdownPart, ...]: return result +@dataclass(frozen=True) +class MarkdownDocumentRenderTask(ReusableRenderTask["DocumentPaint"]): + """Original native layout/paint run on the existing rendering workers.""" + + document: "MarkdownDocument" + width: int + + def execute(self) -> "DocumentPaint": + return self.document.prepare(self.width) + + def accept_result(self, result: object) -> "DocumentPaint": + from textual.document._paint import DocumentPaint + + if not isinstance(result, DocumentPaint) or not result.matches(self.document, self.width): + raise TypeError("Document renderer returned paint for a different acquisition") + return result + + @dataclass(frozen=True) class RichRenderTask(ReusableRenderTask[PreparedRichContent]): source: RichSource diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index 5e5c64199..7b202c24f 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -23,8 +23,9 @@ from toad.markdown_preparation import PreparedMarkdown, PreparedMarkdownPart from toad.block_content import MarkdownBlockContent from toad.layout import trim_trailing_margin -from toad.render_tasks import MarkdownRenderTask -from toad.widgets.viewport_body import MeasuredViewportBody +from toad.render_tasks import MarkdownRenderTask, MarkdownDocumentRenderTask +from toad.work_preparation import retained_bytes +from toad.widgets.viewport_body import MeasuredViewportBody, MeasuredSourceBody, PreparedDocumentBody from toad.widgets.worker_static import WorkerStatic @@ -150,6 +151,17 @@ def trim(self, count: int, *, older: bool) -> None: class PreparedMarkdownContent(WorkerStatic): """Native blocks keep token/link custody; WorkerStatic owns their wrapping.""" + @classmethod + def document_node(cls, block): + # WorkerStatic changes scene execution, not the native content/layout + # declaration. Custom scene behavior still needs its explicit supplier. + cls._require_native_document( + width=WorkerStatic.get_content_width, height=WorkerStatic.get_content_height, + selection=WorkerStatic.get_selection, + ) + cls._require_document_methods({"render_line": WorkerStatic.render_line}) + return cls.native_document_node(block) + @asynccontextmanager async def preparation_publication(self, *, layout: bool): from toad.widgets.history_anchor import HistoryWindow @@ -210,6 +222,17 @@ def set_content(self, content: Content) -> None: def compose(self) -> ComposeResult: yield PreparedCodeLabel(self._highlighted_code, id="code-content", expand=True) + @classmethod + def document_node(cls, block): + cls._require_native_document(constructor=ConversationCodeFence.__init__, + set_content=PreparedCodeFence.set_content) + cls._require_document_methods({"compose": PreparedCodeFence.compose}) + return block.fence_node(label_type=PreparedCodeLabel) + + @classmethod + def document_declarations(cls): + return cls, PreparedCodeLabel + class PreparedConversationMarkdown(MarkdownBlockContent, MeasuredViewportBody, ConversationMarkdown): DEFAULT_CSS = """ @@ -246,6 +269,7 @@ def __init__( markdown_part: PreparedMarkdownPart | None = None, ) -> None: self._prepared_markdown: PreparedMarkdown | None = None + self.document = None self._markdown_part = markdown_part factory = self._make_parser if parser_factory is None else parser_factory super().__init__(markdown, name=name, id=id, classes=classes, @@ -284,9 +308,29 @@ def _initialize_document(self, markdown: str | None) -> AwaitComplete: def retire_body_resources(self) -> None: """Release reconstructible preparation with the native retirement.""" self._prepared_markdown = None - - async def materialize_native_body(self) -> None: - await self._update_body_source(self.source) + self.document = None + + async def materialize_native_body(self): + if self.document is not None and self.document.source == self.source: + # Width/style demand changes presentation, not parser or resolved + # link acquisition. Borrow this original delivered source resource. + return await self._prepare_document(self.document.with_presentation(self)) + return await self._update_body_source(self.source) + + async def materialize_interactive_body(self): + # The original pointer owner acquires actual controls before choosing + # a receiver. It never dispatches a synthetic click against paint. + await self.publish_body(partial(ConversationMarkdown.update, self, self.source)) + + @classmethod + def document_root(cls, document, children): + cls._require_document_methods({ + "render": Widget.render, + "get_content_width": MeasuredViewportBody.get_content_width, + "get_content_height": MeasuredViewportBody.get_content_height, + "process_layout": ConversationMarkdown.process_layout, + }) + return cls.native_document_root(document, children) def update(self, markdown: str) -> AwaitComplete: return self.publish_body(partial(self._update_body_source, markdown)) @@ -294,18 +338,73 @@ def update(self, markdown: str) -> AwaitComplete: def append(self, markdown: str) -> AwaitComplete: return self.publish_body(partial(self._append_body_source, markdown)) - def _update_body_source(self, markdown: str) -> AwaitComplete: + async def _update_body_source(self, markdown: str): if self._markdown_part is not None and self._markdown_part.text != markdown: self._markdown_part = None - return super().update(markdown) + if not self.partitionable_syntax: + # An arbitrary parser/factory hasn't declared the conversation + # source contract. Its original scene producer still owns blocks, + # callbacks and any additional token behavior. + return await ConversationMarkdown.update(self, markdown) + self._markdown = markdown + parser = self._parser_factory() + async with self.lock: + tokens = await self._parse_tokens(parser, markdown, use_thread=True) + if tokens is None: + return False + document = self.acquire_document(markdown, tokens) + return await self._prepare_document(document) + + async def _prepare_document(self, document): + self.document = document + width = self._body_measurement.width + self.styles.gutter.width + if width <= 0: + return MeasuredSourceBody(self._body_measurement.width, self.measured_rows, 1, + root_empty=self.is_empty) + app = self.app + task = MarkdownDocumentRenderTask(document, width) + paint = (await app.render_processes.submit(task) if isinstance(app, ToadApp) + else await asyncio.to_thread(task.execute)) + cost = (await app.preparation.run_thread(retained_bytes, paint) + if isinstance(app, ToadApp) else await asyncio.to_thread(retained_bytes, paint)) + if (not self.is_attached or self._closing or self._pruning + or self.document is not document or not paint.is_current(self, width) + or self._body_measurement.width + self.styles.gutter.width != width): + return MeasuredSourceBody(self._body_measurement.width, self.measured_rows, 1, + root_empty=self.is_empty) + paint = paint.with_presentation(document) + self.loading = False + self._table_of_contents = paint.table_of_contents + self.post_message(Markdown.TableOfContentsUpdated(self, self.table_of_contents).set_sender(self)) + return PreparedDocumentBody(paint, cost) + + def goto_anchor(self, anchor: str) -> bool: + measurement = self._body_measurement + if isinstance(measurement, PreparedDocumentBody) and measurement.ready(self): + region = measurement.paint.anchor_region(anchor) + if region is None: + return False + self.scroll_to_region(region, top=True) + return True + return super().goto_anchor(anchor) - def _append_body_source(self, markdown: str) -> AwaitComplete: + async def _append_body_source(self, markdown: str): if self._markdown_part is not None and self._markdown_part.text != self.source + markdown: self._markdown_part = None - return super().append(markdown) + return await self._update_body_source(self.source + markdown) def on_unmount(self) -> None: self._prepared_markdown = None + self.document = None + + def on_resize(self) -> None: + if self.document is not None and self._body_viewport is None: + self.call_later(self.restore_body) + + def notify_style_update(self) -> None: + super().notify_style_update() + if self.is_attached and self.document is not None and self._body_viewport is None: + self.call_later(self.restore_body) async def _parse_tokens( self, parser: MarkdownIt | _ThreadLocalPathParser, markdown: str, *, use_thread: bool, diff --git a/src/toad/widgets/streaming_markdown.py b/src/toad/widgets/streaming_markdown.py index dd8b0aed6..2a889c069 100644 --- a/src/toad/widgets/streaming_markdown.py +++ b/src/toad/widgets/streaming_markdown.py @@ -14,15 +14,15 @@ from textual.widgets.markdown import MarkdownStream from textual.widget import Widget from textual.app import ComposeResult +from textual.widgets import Markdown +from toad.widgets.viewport_body import MaterializingBody, PreparedDocumentBody from toad.widgets.prepared_markdown import PreparedContentRange, PreparedConversationMarkdown from toad.widgets.committed_presentation import SnapshotPresentation -from toad.conversation_markdown import _ThreadLocalPathParser -from toad.render_tasks import MarkdownPartsTask, MarkdownRenderTask +from toad.conversation_markdown import ConversationMarkdown, _ThreadLocalPathParser +from toad.render_tasks import MarkdownPartsTask, MarkdownRenderTask, MarkdownSyntaxRenderTask from toad.markdown_preparation import PreparedMarkdownPart -from toad.widgets.transcript_fragments import RenderBudget from toad.widgets.presentation_window import PresentationBudget, protected_presentations -from toad.widgets.viewport_body import MaterializingBody class StreamingMarkdown(PreparedContentRange, SnapshotPresentation, PreparedConversationMarkdown): @@ -52,6 +52,45 @@ def retained_source_bytes(self) -> int: def capture_admission(self): return self._content_generation, self.start, self.stop + def _heading_owners(self): + for part_index, child in enumerate(self.fragment_views, self.start): + for level, title, block_id in child.table_of_contents: + yield (level, title, f"part-{part_index}-{block_id}"), child, block_id + + @property + def table_of_contents(self): + if not self.partitionable_syntax: + return super().table_of_contents + return [entry for entry, _child, _block_id in self._heading_owners()] + + def goto_anchor(self, anchor: str) -> bool: + if not self.partitionable_syntax: + return super().goto_anchor(anchor) + headings = tuple(self._heading_owners()) + selected = self.anchor_id_for([entry for entry, _child, _block_id in headings], anchor) + for entry, child, block_id in headings: + if entry[2] != selected: + continue + measurement = child._body_measurement + if isinstance(measurement, PreparedDocumentBody): + if not measurement.ready(child): + return False + for heading in measurement.paint.headings: + if heading.entry[2] == block_id and heading.placement is not None: + child.scroll_to_region(heading.placement.region, top=True) + return True + return False + for block in child.children: + if block.id == block_id: + block.scroll_visible(top=True) + return True + return False + + def on_markdown_table_of_contents_updated(self, event: Markdown.TableOfContentsUpdated): + if event.markdown in self.fragment_views: + event.stop() + self.post_message(Markdown.TableOfContentsUpdated(self, self.table_of_contents)) + @property def has_newer_source(self) -> bool: return self.stop < len(self.fragments) @@ -68,6 +107,13 @@ async def _prepare_parts(self, parts, current: Callable[[], bool]) -> bool: async def materialize_native_body(self) -> None: await self._update_content(self.source, append=False) + async def materialize_interactive_body(self): + # This source range owns real prefix/disclosure widgets. Its document + # children own native controls; do not flatten those roles into paint. + await self.materialize_body() + for child in self.fragment_views: + await child.materialize_interactive_body() + @asynccontextmanager async def retirement_custody(self): async with self._content_lock: @@ -135,26 +181,28 @@ async def _publish_content(self, source: str, text: str, append: bool, self._needs_full_markdown_update = True if self._markdown_part is not None and self._markdown_part.text != source: self._markdown_part = None - fragments = (await self.app.render_processes.submit(MarkdownPartsTask(self.acquired_source(source))) - if self._paginate and self.partitionable_syntax - and not RenderBudget().fits(source) else ()) + fragments = () + if self.partitionable_syntax: + acquired = self.acquired_source(source) + fragments = (await self.app.render_processes.submit(MarkdownPartsTask(acquired)) + if self._paginate else + (await self.app.render_processes.submit(MarkdownSyntaxRenderTask(acquired)),)) if not is_current(): return - if len(fragments) <= 1: - if fragments: - self._markdown_part = fragments[0] + if not self.partitionable_syntax: if self.fragments: await self.remove_children(self.fragment_views) self.fragments, self._fragment_views = (), () self.start = self.stop = 0 - # Paged views replaced the native roots. Reconstruct the - # body before native append can update its final block. self._needs_full_markdown_update = True + # Custom grammar/factory behavior belongs to this original + # native owner, including its block callbacks and root prefix. + # It cannot be replaced by conversation-syntax source parts. if (append and self.source + text == source and not self._needs_full_markdown_update): - await self._append_body_source(text) + await ConversationMarkdown.append(self, text) else: - await self._update_body_source(source) + await ConversationMarkdown.update(self, source) if is_current(): self._needs_full_markdown_update = False return diff --git a/src/toad/widgets/viewport_body.py b/src/toad/widgets/viewport_body.py index 22609c5ac..b7356c141 100644 --- a/src/toad/widgets/viewport_body.py +++ b/src/toad/widgets/viewport_body.py @@ -13,6 +13,7 @@ from time import monotonic from dataclasses import dataclass, replace from abc import ABC, abstractmethod +from typing import TYPE_CHECKING from textual.strip import Strip from toad.rich_preparation import PreparedPaintSource, PreparedRichContent from toad.work_preparation import retained_bytes @@ -30,6 +31,9 @@ from textual.worker import Worker from textual.await_complete import AwaitComplete +if TYPE_CHECKING: + from textual.document._paint import DocumentPaint + class ViewportBody: """Nominal contract implemented by source-owning document widgets.""" @@ -215,6 +219,10 @@ def paint_ready(self, body) -> bool: """A measured extent or mutable native tree is not captured paint.""" return False + def source_empty(self, body, native_empty) -> bool: + """Displayed source membership may outlive its scene descendants.""" + return native_empty + def requires_geometry(self, body) -> bool: """Settled native content needs its box; retained extent does not.""" return not self.dormant @@ -278,6 +286,10 @@ def cost(self, body): return self.widgets def height(self, body, width, measure): + # This state owns no paint to relabel. Preserve preceding row extent + # while recording the original layout's new preparation demand. + if width != self.width: + body._update_body_measurement(replace(self, width=width)) return self.rows def render(self, body, crop, render_live): @@ -289,6 +301,19 @@ async def restore(self, body): await body.materialize_body() +@dataclass(frozen=True, kw_only=True) +class MeasuredSourceBody(MeasuredBody): + """Committed source membership remains when its paint is released.""" + + root_empty: bool + + def source_empty(self, body, native_empty): + return self.root_empty + + def invalidated(self): + return self + + @dataclass(frozen=True) class LiveBody(MeasuredBody): def released(self): @@ -416,6 +441,9 @@ def ready(self, body): def paint_ready(self, body): return self.previous.paint_ready(body) + def source_empty(self, body, native_empty): + return self.previous.source_empty(body, native_empty) + def requires_geometry(self, body): # Pending work without preceding pixels still lays out native children. # Dormancy describes interaction, not that writer's layout demand. @@ -462,7 +490,7 @@ def admitted(self): def style_updated(self, body): # Descendant writes belong to the new source, not the old captured # rows. Actual inherited paint/width changes still retire those rows. - return self if self.paint_ready(body) else self.invalidated() + return self._updated(self.previous.style_updated(body)) def resized(self, size): return self._updated(self.previous.resized(size)) @@ -516,6 +544,8 @@ def publication_finished(self, body, worker, result): if body._body_measurement is self: # Only the current writer exposes its newly committed native # tree. A newer writer still borrows the preceding pixels. + if isinstance(result, BodyMeasurement): + return result return LiveBody(self.width, self.rows, self.widgets) return self.previous return self._updated(self.previous.publication_finished(body, worker, result)) @@ -529,12 +559,15 @@ def publication_failed(self, body, worker): @dataclass(frozen=True, kw_only=True) -class RenderedBody(MeasuredBody): +class RenderedBody(MeasuredSourceBody): content: PreparedRichContent resource_bytes: int style_revision: int paint_state: PaintState + def invalidated(self): + return MeasuredSourceBody(self.width, self.rows, self.widgets, root_empty=self.root_empty) + @property def dormant(self): return True @@ -568,7 +601,7 @@ def paint_bytes(self): def height(self, body, width, measure): if width != self.width: - body.invalidate_body() + body._update_body_measurement(replace(self.invalidated(), width=width)) return self.rows def render(self, body, crop, render_live): @@ -581,7 +614,7 @@ def get_selection(self, body, selection, select_live): return (selection.extract(self.content.text), '\n') if self.paint_ready(body) else None def released(self): - return MeasuredBody(self.width, self.rows, self.widgets) + return self.invalidated() async def restore(self, body): if not self.ready(body): @@ -589,6 +622,124 @@ async def restore(self, body): await body.materialize_body() +@dataclass(frozen=True) +class PreparedDocumentBody(BodyMeasurement): + """Worker-owned intrinsic paint, distinct from captured scene pixels.""" + + paint: "DocumentPaint" + resource_bytes: int + + @property + def width(self): + return self.paint.content_size.width + + @property + def rows(self): + return self.paint.content_size.height + + @property + def widgets(self): + return 1 + + @property + def dormant(self): + return True + + def source_empty(self, body, native_empty): + return self.paint.root_empty + + def ready(self, body): + return (self.paint_ready(body) and self.paint.document is body.document + and not body.loading) + + def paint_ready(self, body): + # This is the actual publication participant's native admission. A + # replacement source may borrow these rows, but cannot claim readiness. + return (body.is_mounted and not body._closing + and self.paint.is_current(body, self.paint.width)) + + def cost(self, body): + return body.materialized_widget_count + + @property + def paint_bytes(self): + return self.resource_bytes + + def height(self, body, width, measure): + if width != self.width: + body._update_body_measurement(self.invalidated(width)) + return self.rows + + def render(self, body, crop, render_live): + return self.paint.render_lines(crop) + + def get_selection(self, body, selection, select_live): + # Pointer selection acquires original scene controls before native + # targeting. Whole-document copy can borrow each original leaf owner. + if not self.paint_ready(body) or selection.start is not None or selection.end is not None: + return None + parts = (leaf.selected_text(selection) for leaf in self.paint.leaves) + text = "".join(value[0] + value[1] for value in parts if value is not None).rstrip("\n") + return text, "\n" + + def style_updated(self, body): + if self.paint_ready(body): + return self + if self.paint.document is body.document: + # Membership publication can rematch the host's :empty rules. + # The worker already resolved those same source-dependent rules. + # Reuse only after full acquired input equality, never by copying + # the new participant's invalidation counter onto old pixels. + document = body.document.with_presentation(body) + if self.paint.matches(document, self.paint.width): + body.document = document + return replace(self, paint=self.paint.with_presentation(document)) + return self.invalidated() + + def invalidated(self, width=None): + return PendingDocumentBody(self.paint, self.resource_bytes, + self.width if width is None else width) + + def resized(self, size): + return (self if size.width == self.paint.width else + self.invalidated(size.width - self.paint.gutter.width)) + + def released(self): + return MeasuredSourceBody(self.width, self.rows, self.widgets, + root_empty=self.paint.root_empty) + + async def restore(self, body): + if not self.ready(body): + body.invalidate_body() + await body.materialize_body() + + +@dataclass(frozen=True) +class PendingDocumentBody(PreparedDocumentBody): + """Original document membership/extent awaiting new presentation paint.""" + + requested_width: int + + @property + def width(self): + return self.requested_width + + def ready(self, body): + return False + + def paint_ready(self, body): + return False + + def invalidated(self, width=None): + return self if width is None or width == self.width else replace(self, requested_width=width) + + def style_updated(self, body): + return self + + def render(self, body, crop, render_live): + return render_live(crop) + + class MeasuredViewportBody(ViewportBody): """The body owns Live, Rendered and Measured behavior, not copied flags.""" @@ -603,6 +754,10 @@ def __init__(self, *args, **kwargs): def body_dormant(self): return self._body_measurement.dormant + @property + def is_empty(self): + return self._body_measurement.source_empty(self, super().is_empty) + @property def body_ready(self): return self._body_measurement.ready(self) @@ -674,7 +829,13 @@ def invalidate_body(self): def _update_body_measurement(self, measurement): if measurement is self._body_measurement: return + empty = self.is_empty self._body_measurement = measurement + if self.is_empty != empty: + self.update_node_styles() + # A membership pseudo can change the native admission even when + # its stylesheet has no different rule values to notify about. + self._body_measurement = self._body_measurement.style_updated(self) # The resource owns descendant participation and cover selection as # well as extent. Publish that change before any native prune awaits; # NodeList removal happens later and cannot invalidate it for us. @@ -708,19 +869,29 @@ async def prepare_input(self, event): # Native controls are actual resources, not pixels. Restore them at # interaction, retaining read-only rows for ordinary scroll/reentry. if self.body_dormant: - await self.materialize_body() + await self.materialize_interactive_body() # Native input routing must select its target after the new scene # commits, outside the mutation lock; no synthetic event replay. painted = asyncio.Event() self.screen.call_after_refresh(painted.set) await painted.wait() + async def materialize_interactive_body(self): + """Acquire actual native controls through their original body owners.""" + await self.materialize_body() + children = tuple(child for child in walk_depth_first(self, ViewportBody, with_root=False) + if not any(isinstance(ancestor, ViewportBody) + for ancestor in child.ancestors + if ancestor is not self and self in ancestor.ancestors)) + for child in children: + await child.materialize_interactive_body() + async def materialize_body(self): if not self.body_dormant or not self.is_attached or self._closing: return await self._body_measurement.materialize(self) - def publish_body(self, work: Callable[[], Awaitable[None | bool]], *, exit_on_error=False) -> AwaitComplete: + def publish_body(self, work: Callable[[], Awaitable[None | bool | BodyMeasurement]], *, exit_on_error=False) -> AwaitComplete: """Source updates and reentry share the original materialization worker.""" operation = self._body_measurement.start_materialization(self, work, exit_on_error=exit_on_error) return AwaitComplete(operation.materialize(self)) @@ -797,6 +968,7 @@ def capture_native_paint(self, current, *, sources=None): compositor = self.screen._compositor style_revision = self._subtree_style_revision paint_state = self._resolved_paint_state() + root_empty = self.is_empty captured = tuple(compositor.published_geometry((self,))) if not captured: return unprepared.prepare_publication(self) @@ -819,6 +991,7 @@ async def measured(): return RenderedBody( current.width, current.rows, widgets, content=content, resource_bytes=size_bytes, style_revision=style_revision, paint_state=paint_state, + root_empty=root_empty, ) return measured() From 35e694c579bbf2ce99d198b603f210b41ac4b4ba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 20:42:43 -0400 Subject: [PATCH 03/10] Keep original transcript pages and reader custody with the session Integrate Erdos session/page lifetime handoff. The operational session retains the original prepared page objects, source scope and admitted ranges while disposable native publishers detach. View-owned transcripts still close on detach through their existing base behavior; the session declaration supplies retention polymorphically. Reader intent binds the actual source fragment and its row before retirement awaits, and restores only after committed placement. New edge/End acquisition chooses its requested range independently of parked restoration. Eight handoff modules compile; all frontend source compiles and diff check passes. Prior genuine reader-return row mismatch remains negative; latest matched combined saved App reader was unreached due native geometry transport, fixed separately by the native owner. Filtered-prefix, rewrap character anchoring and rendered-paint retention remain unqualified. No test, install or provider attempt repeated for this source checkpoint. --- src/toad/screens/main.py | 4 + src/toad/session_presentation.py | 30 +++-- src/toad/transcript_preparation.py | 44 +++++-- src/toad/transcript_publication.py | 126 +++++++++++++++++-- src/toad/transcript_source_preparation.py | 7 +- src/toad/widgets/conversation.py | 17 ++- src/toad/widgets/history_anchor.py | 103 ++++++++++++--- src/toad/widgets/transcript_history.py | 147 +++++++++++++++++++--- 8 files changed, 411 insertions(+), 67 deletions(-) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 21df0644d..6908b7607 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -194,6 +194,9 @@ async def prepare_presentation(self) -> None: async def retire_presentation(self) -> None: from toad.widgets.session_thread_sidebar import SessionThreadSidebar + # The departing scene is still committed here. Awaiting source/sidebar + # retirement can publish a new layout which no longer contains it. + self.presentation.capture_reader() await super().retire_presentation() if sidebar := self.query_one_optional(SessionThreadSidebar): await sidebar.retire_presentation() @@ -254,6 +257,7 @@ def _make_conversation(self) -> Conversation: self.project_path, self._agent, self._agent_session_id, self._session_pk, self.app.session_tracker.sessions[self.id].initial_title, initial_prompt=self._initial_prompt, + transcript=self.presentation.transcript, ) self._initial_prompt = None return conversation diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 9787e2b25..bf01893ac 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -43,18 +43,20 @@ def capture(cls, conversation: Conversation) -> "SessionViewState": editor = conversation.prompt.prompt_text_area return cls( editor.capture_editor_state(), conversation.visible_categories, - ReaderPosition.capture(conversation.window), + conversation.transcript.reader_position or ReaderPosition.capture(conversation.window), editor.shell_mode, conversation.input_histories, conversation.take_initial_prompt(), ) + def prepare_transcript(self, conversation: Conversation) -> None: + conversation.visible_categories = self.visible_categories + conversation.transcript.reader_position = self.reader_position + def restore(self, conversation: Conversation) -> None: conversation.input_histories = self.input_histories - conversation.visible_categories = self.visible_categories editor = conversation.prompt.prompt_text_area editor.restore_editor_state(self.editor) editor.shell_mode = self.shell_mode self.reader_position.restore(conversation.window) - conversation.transcript.reader_position = self.reader_position class SessionSurfaceLifetime(ABC): @@ -144,10 +146,13 @@ async def close(self) -> None: class OperationalSessionPresentation(EditorSessionSurfaceLifetime): - """Own the actual mounted view; eviction retains only its existing editor state.""" + """Own the editor and logical transcript independently of native membership.""" def __init__(self) -> None: super().__init__() + from toad.transcript_publication import SessionTranscriptPresentation + + self.transcript = SessionTranscriptPresentation() self.sources = OperationalSessionSources() self.widget: Conversation | None = None @@ -162,8 +167,10 @@ def retained_widget_count(self) -> int: @property def retained_source_bytes(self) -> int: - return (self.widget.window.document_viewport.retained_source_bytes - if self.widget is not None else 0) + return self.transcript.retained_source_bytes + ( + self.widget.window.document_viewport.retained_source_bytes + if self.widget is not None else 0 + ) @property def retained_paint_bytes(self) -> int: @@ -206,10 +213,13 @@ async def acquire(self, screen: "MainScreen"): conversation._initial_prompt = self.state.initial_prompt await content.mount(conversation, before=slot) yield conversation - await self.attach_binding(conversation) + if self.state is not None: + self.state.prepare_transcript(conversation) + await self.transcript.restore_documents() if self.state is not None: self.state.restore(conversation) self.state = None + await self.attach_binding(conversation) await conversation.prepare_retained_session() conversation.display = True if returning: @@ -218,6 +228,7 @@ async def acquire(self, screen: "MainScreen"): async def close(self, screen: "MainScreen") -> None: await screen.app.workspace_chrome.native.dispose(screen) + await self.transcript.close() self.state = None await self.sources.close() @@ -228,6 +239,11 @@ async def evict(self) -> None: self.state = SessionViewState.capture(conversation) await self.remove_native_tree() + def capture_reader(self) -> None: + """Retain the source point before retirement can discard native placement.""" + if (conversation := self.widget) is not None: + self.transcript.reader_position = ReaderPosition.capture(conversation.window) + async def remove_native_tree(self) -> None: if (conversation := self.widget) is None: return diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index ce87b57d1..cbc0a418f 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -28,11 +28,12 @@ ) -@dataclass(frozen=True) +@dataclass class PreparedTranscriptPage: page: TranscriptPage fragments: tuple[TranscriptFragment, ...] retained_bytes: int + admission: tuple[int, int] | None = None @dataclass(frozen=True) @@ -90,6 +91,28 @@ class PreparedPageSource(ABC): through: TranscriptCursor loader: Callable[..., Awaitable[TranscriptPage]] | None + def __init__(self) -> None: + # Custody moves here when native membership is revoked. These are the + # original admitted resources, not another cache of transport pages. + self.admitted: tuple[PreparedTranscriptPage, ...] = () + self.projection_source: ProjectedTranscriptSource | None = None + + @property + def retained_source_bytes(self) -> int: + return sum(page.retained_bytes for page in self.admitted) + ( + self.projection_source.retained_source_bytes if self.projection_source is not None else 0 + ) + + def park(self, pages: tuple[PreparedTranscriptPage, ...], + projection: ProjectedTranscriptSource | None = None) -> None: + if self.closed: + raise ValueError("Cannot retain a revoked transcript source") + self.admitted, self.projection_source = pages, projection + + def release_admission(self) -> None: + """Native acquisition takes these same resources, not materialized copies.""" + self.admitted, self.projection_source = (), None + @property def closed(self) -> bool: return self.scope.closed @@ -105,9 +128,11 @@ def prefetch( ) -> AsyncIterator[PreparedTranscriptPage]: pass - @abstractmethod def close(self) -> None: - pass + self.runtime.discard_scope(self.scope) + if self.projection_source is not None: + self.projection_source.close() + self.release_admission() class TranscriptPageProjection(ABC): @@ -126,7 +151,7 @@ async def project(self, page: PreparedTranscriptPage, runtime: PreparationRuntim matches = await runtime.submit(TranscriptFilterWork( page.fragments, self.selected, len(page.fragments), len(page.fragments), )) - return replace(page, fragments=matches.fragments) + return replace(page, fragments=matches.fragments, admission=None) @dataclass(frozen=True) @@ -177,9 +202,10 @@ class TranscriptPageBuffer(PreparedPageSource): """ def __init__( - self, loader: Callable[..., Awaitable[TranscriptPage]], through: TranscriptCursor, + self, loader: Callable[..., Awaitable[TranscriptPage]] | None, through: TranscriptCursor, runtime: PreparationRuntime, ) -> None: + super().__init__() self.loader, self.through, self.runtime = loader, through, runtime self.scope = PreparationScope() self._blocked: OrderedDict[PageRequest, None] = OrderedDict() @@ -189,10 +215,12 @@ def closed(self) -> bool: return self.scope.closed def close(self) -> None: - self.runtime.discard_scope(self.scope) + super().close() self._blocked.clear() async def get(self, request: PageRequest) -> PreparedTranscriptPage: + if self.loader is None: + raise ValueError("This transcript has no saved-source reader") result = await self.runtime.submit(TranscriptPageWork(self.scope, self.loader, self.through, request)) self._blocked.pop(request, None) return result @@ -266,6 +294,7 @@ def __init__( runtime: PreparationRuntime, projection: TranscriptPageProjection, upstream: PreparedPageSource | None = None, ) -> None: + super().__init__() self.loader, self.runtime, self.projection = loader, runtime, projection self._upstream = upstream self.through = boundary.page.after @@ -366,10 +395,9 @@ async def prefetch( yield prepared def close(self) -> None: + super().close() if self._raw is not None: self._raw.close() - else: - self.runtime.discard_scope(self.scope) self._projected_boundary = None self._upstream = None self._raw = None diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 7a8ef63ac..f1b1ede3e 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -16,6 +16,7 @@ from toad.widgets.conversation import Conversation, Window, Contents from toad.widgets.history_anchor import ReaderPosition from toad.widgets.transcript_history import TranscriptHistory + from toad.transcript_preparation import PreparedPageSource class TranscriptPublication(ABC): @@ -339,6 +340,8 @@ def defer(self, publication: TranscriptPublication) -> None: if self.pending.empty(): self.pending.put_nowait(publication) view = self.owner.view + if view is None: + return view.retire_core_observations(view.app.coordination_access.events) view.observe_core_callback(view.app.coordination_access.events, self.resume) @@ -348,6 +351,8 @@ def resume(self, _event=None) -> bool: return False if self.worker is None or self.worker.is_finished: view = self.owner.view + if view is None or not view.is_attached: + return False view.retire_core_observations(view.app.coordination_access.events) self.worker = view.run_worker(self.publish, group="transcript-source") return True @@ -500,10 +505,12 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: class TranscriptPresentation: - """Owns projection frontier, invalidation and exclusive checkpoint lifetime.""" + """Logical document custody; each attached native publisher is disposable.""" - def __init__(self, view: Conversation) -> None: - self._view = weakref.ref(view) + def __init__(self, view: Conversation | None = None) -> None: + self._view = weakref.ref(view) if view is not None else lambda: None + self.documents: tuple[PreparedPageSource, ...] = () + self.closed = False self.generation = 0 self.dirty = False self.checkpoint_required = False @@ -516,6 +523,50 @@ def __init__(self, view: Conversation) -> None: def view(self) -> Conversation | None: return self._view() + def bind(self, view: Conversation) -> None: + if self.closed: + raise ValueError("Transcript lifetime has ended") + if self.view is not None and self.view is not view: + raise ValueError("Transcript already has a native publisher") + self.invalidate() + self._view = weakref.ref(view) + + @property + def retained_source_bytes(self) -> int: + return sum(source.retained_source_bytes for source in self.documents) + + async def restore_documents(self) -> None: + """Transfer original prepared data back to native consumers, without a read.""" + from toad.widgets.transcript_history import TranscriptHistory + + view = self.view + if view is None or not view.is_attached: + return + generation, window, contents = self.generation, view.window, view.contents + def current(): + return (self.view is view and view.is_attached and not view._closing + and self.generation == generation + and view.window is window and view.contents is contents) + + for source in self.documents: + if not current() or source.closed: + return + history = TranscriptHistory.from_source(source) + await history.prepare_body(current, selected=view.visible_categories) + if not current(): + return + accepted = False + try: + await contents.mount(history) + if not current(): + return + history.acquire_document() + self.documents = tuple(document for document in self.documents if document is not source) + accepted = True + finally: + if not accepted and history.is_attached: + await history.remove() + @property def histories(self) -> tuple[TranscriptHistory, ...]: """Canonical saved sources are direct children of this transcript. @@ -548,6 +599,9 @@ async def restore_native(self, agent) -> None: from toad.screens.session_view import SessionView view = self.view + if view is None: + return + await self.restore_documents() history = next(iter(self.histories), None) source = view.query_ancestor(SessionView) publication = self.capture(CanonicalSourcePublication) @@ -571,8 +625,7 @@ async def reveal_retained(self, history: TranscriptHistory) -> None: view = self.view if view is None or not view.is_attached: return - self.displayed_cursor = history.committed_cursor - self.reader_position = None + self.painted(history.committed_cursor, reader_revision=view.window.scroll_revision) if (loading := view.query_one_optional(ThreadLoading)) is not None: await loading.remove() view.remove_class("-initial-loading") @@ -615,14 +668,28 @@ def record() -> None: if window.is_attached and contents.is_attached: self.displayed_cursor = cursor if reader_revision is not None: - position, self.reader_position = self.reader_position, None - if position is not None and window.scroll_revision == reader_revision: - position.restore(window) + position = self.reader_position + if position is not None: + if (window.scroll_revision != reader_revision + or not position.current(window)): + self.reader_position = None + elif position.restore(window): + self.reader_position = None + else: + # Native membership may precede its first measured + # frame. Keep the original reader until that source + # has placement; a preview/clamp cannot consume it. + view.call_after_refresh(record) view.call_after_refresh(record) - def source_changed(self) -> None: + def source_changed(self, view: Conversation) -> None: + # Construction and an old view's teardown cannot revoke the new + # publisher. Operational binding before mount is owned by the session. + if self.view is not view or not view.is_attached: + return self.source_requests.cancel() self.invalidate() + self._close_documents() self.dirty = self.checkpoint_required = False self.displayed_cursor = None self.reader_position = None @@ -671,8 +738,25 @@ def source_work_finished(self, history) -> None: self.retry() async def close(self) -> None: + self.closed = True await self.suspend() + self._close_documents() + for history in self.histories: + if history._page_buffer is not None: + history._page_buffer.close() self._view = lambda: None + self.displayed_cursor = None + self.reader_position = None + + def _close_documents(self) -> None: + for source in self.documents: + source.close() + self.documents = () + + async def detach(self, view: Conversation) -> None: + """A view-owned transcript has no remaining consumer after detach.""" + if self.view is view: + await self.close() async def suspend(self) -> None: """Revoke in-flight publications while retaining this source's mounted frontier.""" @@ -782,3 +866,27 @@ async def _retire_presentations(self, contents, output, candidates) -> None: await output.retire_presentations(candidates) finally: await contents.remove_children(candidates) + + +class SessionTranscriptPresentation(TranscriptPresentation): + """The operational session retains its source after native view eviction.""" + + async def detach(self, view: Conversation) -> None: + if self.view is not view: + return + await self.suspend() + if self.closed or self.view is not view: + return + generation = self.generation + documents = [] + for history in self.histories: + if history._source_state.retirement_source().reports_coverage: + documents.append(await history.park_document()) + if self.closed or self.view is not view or self.generation != generation: + for source in documents: + source.close() + return + self._close_documents() + self.documents = tuple(documents) + self._view = lambda: None + self.displayed_cursor = None diff --git a/src/toad/transcript_source_preparation.py b/src/toad/transcript_source_preparation.py index 63797ac81..89bdd0214 100644 --- a/src/toad/transcript_source_preparation.py +++ b/src/toad/transcript_source_preparation.py @@ -89,16 +89,17 @@ def observe_source(self) -> None: self.prepare_scroll() async def on_unmount(self) -> None: + # Parked preparation belongs to the logical document. Unmount revokes + # this publisher, not the original source scope transferred with it. + self._source_state.retire_preparation(self._page_buffer) self._source_state = RetiredSourceTranscript(self._source_state.retirement_source()) self._generation += 1 self._prefetch_intent = None self.window.histories.discard(self) - if self._page_buffer is not None: - self._page_buffer.close() await self.close_source_reader() async def close_source_reader(self) -> None: - """Native prepared-page scope is closed by the common lifetime.""" + """Close independent source subscriptions, separately from page custody.""" def _layout_changed(self, _screen) -> None: self._scroll_changed() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 8ae3fa588..c5fe515ed 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -440,15 +440,17 @@ def __init__( session_pk: int | None = None, session_title: str | None = None, initial_prompt: str | None = None, + transcript: TranscriptPresentation | None = None, ) -> None: super().__init__() self.turns = ConversationTurn(self._turn_changed, lambda: self.agent.presentation.turns if self.agent else None) self._initialize_session(project_path, agent, agent_session_id, session_pk, - session_title, initial_prompt) + session_title, initial_prompt, transcript) def _initialize_session(self, project_path, agent=None, agent_session_id=None, - session_pk=None, session_title=None, initial_prompt=None) -> None: + session_pk=None, session_title=None, initial_prompt=None, + transcript: TranscriptPresentation | None = None) -> None: project_path = project_path.resolve().absolute() self.set_reactive(ConversationSessionBinding.project_path, project_path) @@ -500,14 +502,15 @@ def _initialize_session(self, project_path, agent=None, agent_session_id=None, self.goal_observation = GoalObservation(self) self.goal_controls = GoalSession(self) self.delivery_observation = InputDeliveryObservation(self) - self.transcript = TranscriptPresentation(self) + self.transcript = transcript if transcript is not None else TranscriptPresentation() + self.transcript.bind(self) self.tool_expansions: dict[str, bool] = {} async def _release_source_resources(self) -> None: """Join original publications for explicit release and native unmount.""" self.goal_controls.close() - await self.transcript.close() + await self.transcript.detach(self) self.output.retire() await asyncio.gather(self.goal_observation.close(), self.delivery_observation.close()) if self._directory_watcher is not None: @@ -692,9 +695,11 @@ def restore_position(): not self.is_attached or self.visible_categories != selected or window.scroll_revision != revision + or not position.current(window) ): return - position.restore(window) + if not position.restore(window): + self.call_after_refresh(restore_position) self.call_after_refresh(restore_position) @@ -1808,7 +1813,7 @@ async def post_welcome(self) -> None: """Post any welcome content.""" def watch_agent(self, agent: AgentBase | None) -> None: - self.transcript.source_changed() + self.transcript.source_changed(self) # A presentation remount is not a fresh attachment. Start at the # Agent's current projection/floor so previously queued receipts cannot # revive proof after its reducer entered quarantine or evidence loss. diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index cd79de943..e08525b30 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -21,6 +21,7 @@ from toad.widgets.tool_call import ToolCall from toad.widgets.transcript_history import TranscriptPageAdmission from toad.transcript_source_preparation import TranscriptSourcePreparation + from toad.widgets.transcript_fragments import TranscriptFragment class WindowRestoration(ABC): @@ -29,11 +30,11 @@ class WindowRestoration(ABC): def current(self, window: "HistoryWindow") -> bool: return True - def restore(self, window: "HistoryWindow") -> None: + def restore(self, window: "HistoryWindow") -> bool: if not self.current(window): - return + return False with self.geometry(window): - self._restore(window) + return self._restore(window) @classmethod @contextmanager @@ -63,40 +64,52 @@ def _translate_motion(window: "HistoryWindow", destination: float, compensation: window.scroll_target_y = destination + compensation @abstractmethod - def _restore(self, window: "HistoryWindow") -> None: ... + def _restore(self, window: "HistoryWindow") -> bool: ... class ReaderPosition(WindowRestoration): """Source-owned reader intent, independent of retired widget geometry.""" + admissions: tuple["TranscriptPageAdmission", ...] = () + @staticmethod def _translate_motion(window: "HistoryWindow", destination: float, compensation: float) -> None: """Explicit native navigation owns its new destination, not the old curve.""" @classmethod def capture(cls, window: "HistoryWindow") -> "ReaderPosition": - from toad.widgets.transcript_history import TranscriptHistory - # Follow intent enters from Textual's native scroll boundary once. if window.follows_tail: return TailReaderPosition() # Parked pagers revoke source work and leave window.histories while # their original admitted pages remain in the native tree. Capture # those page ranges before eviction, including the parked resource. - return OffsetReaderPosition(window.scroll_y, tuple( - admission for history in window.query(TranscriptHistory) - if history.window is window + histories = tuple(window.transcript_histories()) + admissions = tuple( + admission for history in histories for admission in history.capture_reader_admissions() - )) + ) + visible = window.screen._compositor.visible_widgets + record = min(window.visible_history_items( + fragment for history in histories for fragment in history.fragment_views + ), key=lambda fragment: visible[fragment][0].y, default=None) + if record is not None: + return FragmentReaderPosition( + record.fragment, window.scroll_y - HistoryAnchor._offset(record, window), + admissions, + ) + return OffsetReaderPosition(window.scroll_y, admissions) def prepare_history(self, history: "TranscriptSourcePreparation") -> None: - """Tail readers use ordinary newest-page admission.""" + """Restore admitted source ranges; tail readers have no older admission.""" + history.restore_reader_admissions(self.admissions) @dataclass(frozen=True) class TailReaderPosition(ReaderPosition): - def _restore(self, window: "HistoryWindow") -> None: + def _restore(self, window: "HistoryWindow") -> bool: window.anchor() + return True @dataclass(frozen=True) @@ -104,12 +117,43 @@ class OffsetReaderPosition(ReaderPosition): y: float admissions: tuple["TranscriptPageAdmission", ...] - def prepare_history(self, history: "TranscriptSourcePreparation") -> None: - history.restore_reader_admissions(self.admissions) - - def _restore(self, window: "HistoryWindow") -> None: + def _restore(self, window: "HistoryWindow") -> bool: window.release_anchor() window.scroll_to(y=self.y, animate=False, immediate=True) + return True + + +@dataclass(frozen=True) +class FragmentReaderPosition(ReaderPosition): + """Original source record and its reading row, independent of preceding rows. + + The source resource survives native eviction. No old widget, descendant + path or reconstructed content is retained to find its new presentation. + """ + + fragment: TranscriptFragment + offset: float + admissions: tuple["TranscriptPageAdmission", ...] + + def record(self, window: "HistoryWindow"): + return next((record for history in window.transcript_histories() + for record in history.fragment_views + if record.fragment is self.fragment), None) + + def current(self, window: "HistoryWindow") -> bool: + record = self.record(window) + return record is not None and record.display + + def _restore(self, window: "HistoryWindow") -> bool: + window.release_anchor() + record = self.record(window) + if record is None or not record.body_ready: + return False + placed = HistoryAnchor._offset(record, window, require_placement=True) + if placed is None: + return False + window.scroll_to(y=placed + self.offset, animate=False, immediate=True) + return True class HistoryWindow(VerticalScroll): @@ -297,6 +341,20 @@ def visible_history_items(self, items): and clip.overlaps(viewport)): yield item + def transcript_histories(self): + """Read original pager custody without walking rich native descendants.""" + from toad.widgets.transcript_history import TranscriptHistory + + pending = list(reversed(self.children)) + while pending: + node = pending.pop() + if isinstance(node, TranscriptHistory): + yield node + if (projection := node.filter.state.overlay) is not None: + pending.append(projection) + elif not isinstance(node, ViewportBody): + pending.extend(reversed(node.children)) + def reader_anchor(self, fallback: Widget) -> Widget: """Extent publication preserves the reader, not the changed paragraph.""" if self.history_anchor is not None: @@ -439,7 +497,7 @@ def geometry_targets(self) -> tuple[Widget, ...]: return (self.widget,) if self.widget.is_attached else () @staticmethod - def _offset(widget: Widget, window: Widget) -> int: + def _offset(widget: Widget, window: Widget, *, require_placement: bool = False) -> int | None: offset = 0 node = widget compositor = widget.screen._compositor @@ -451,6 +509,8 @@ def _offset(widget: Widget, window: Widget) -> int: else compositor._full_map) while node is not window: placed = geometry.get(node) + if placed is None and require_placement: + return None offset += placed.virtual_region.y if placed is not None else node.virtual_region.y if not isinstance(node.parent, Widget): break @@ -461,7 +521,7 @@ def current(self, window: HistoryWindow) -> bool: return window.scroll_revision == self.scroll_revision @abstractmethod - def _restore(self, window: HistoryWindow) -> None: + def _restore(self, window: HistoryWindow) -> bool: """Apply this policy to the newly committed layout.""" @@ -471,8 +531,9 @@ class TailAnchor(HistoryAnchor): follow_tail: ClassVar[bool] = True - def _restore(self, window: HistoryWindow) -> None: + def _restore(self, window: HistoryWindow) -> bool: window.scroll_y = window.max_scroll_y + return True @dataclass(frozen=True) @@ -482,9 +543,11 @@ class RecordAnchor(HistoryAnchor): virtual_y: int follow_tail: ClassVar[bool] = False - def _restore(self, window: HistoryWindow) -> None: + def _restore(self, window: HistoryWindow) -> bool: if self.widget.is_attached: # This is document placement compensation, not a new user scroll. # scroll_to finishes the current animation even when the delta is # zero. The enclosing restoration translates its original curve. window.scroll_y = self.scroll_y + self._offset(self.widget, window) - self.virtual_y + return True + return False diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 16f52783f..3b6111591 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -29,7 +29,7 @@ from textual.widgets import Static from toad.transcript_filter import TranscriptFilter -from toad.transcript_state import TranscriptState, LiveTranscript, ProvisionalTranscript, LatestViewportRequest +from toad.transcript_state import TranscriptState, LiveTranscript, ProvisionalTranscript, ParkedSourceTranscript, LatestViewportRequest from toad.transcript_source_preparation import TranscriptSourcePreparation from toad.transcript_preparation import ( CategoryProjection, CommittedInterval, PageRequest, PreparedPageSource, PreparedTranscriptPage, TranscriptPageBuffer, @@ -238,13 +238,55 @@ class TranscriptPageView(PreparedContentRange, VerticalGroup): def __init__(self, page: TranscriptPage, *, newest: bool = True, fragments: tuple[TranscriptFragment, ...] | None = None, - batch_size: int = PreparedContentRange.BATCH): - self.page = page + batch_size: int = PreparedContentRange.BATCH, + prepared: PreparedTranscriptPage | None = None): + self.prepared = prepared if prepared is not None else PreparedTranscriptPage( + page, transcript_fragments(page.events) if fragments is None else fragments, 0, + ) + admission = self.prepared.admission self.visible_categories = all_categories() super().__init__( - fragments=transcript_fragments(page.events) if fragments is None else fragments, + fragments=self.prepared.fragments, batch_size=batch_size, newest=newest, ) + if admission is not None: + self.prepared.admission = admission + + @property + def page(self) -> TranscriptPage: + return self.prepared.page + + @page.setter + def page(self, page: TranscriptPage) -> None: + self.prepared.page = page + self.prepared.retained_bytes = 0 + + @property + def fragments(self) -> tuple[TranscriptFragment, ...]: + return self.prepared.fragments + + @fragments.setter + def fragments(self, fragments: tuple[TranscriptFragment, ...]) -> None: + if self.prepared.fragments is not fragments: + self.prepared.fragments = fragments + self.prepared.retained_bytes = 0 + + @property + def start(self) -> int: + return self.prepared.admission[0] + + @start.setter + def start(self, start: int) -> None: + admission = self.prepared.admission + self.prepared.admission = start, admission[1] if admission is not None else start + + @property + def stop(self) -> int: + return self.prepared.admission[1] + + @stop.setter + def stop(self, stop: int) -> None: + self.prepared.admission = self.start, stop def _body(self, fragment: TranscriptFragment) -> TranscriptFragmentView: return TranscriptFragmentView(fragment, self.visible_categories) @@ -254,9 +296,16 @@ def _body(self, fragment: TranscriptFragment) -> TranscriptFragmentView: async def acquire( cls, owner: TranscriptHistory, page: TranscriptPage, *, fragments, batch_size: int, before: Widget, current: Callable[[], bool], newest: bool = True, + prepared: PreparedTranscriptPage | None = None, ) -> AsyncIterator[TranscriptPageView]: """Acquire a page until its original source transfers native custody.""" - view = cls(page, fragments=fragments, batch_size=min(batch_size, cls.BATCH), newest=newest) + view = cls(page, fragments=fragments, prepared=prepared, + batch_size=min(batch_size, cls.BATCH), newest=newest) + # This is a new edge/End demand, not restoration of parked membership. + # A projected boundary can return the same original prepared resource; + # its preceding range must not override the explicitly requested edge. + selected = cls.initial_slice(view.fragments, view.batch_size, newest) + view.start, view.stop = selected.start, selected.stop view.visible_categories = owner.selected_categories with ExitStack() as acquisition: acquisition.callback(owner.remove_children, (view,)) @@ -337,14 +386,15 @@ def message_category(self) -> None: def __init__(self, page: TranscriptPage, loader: Callable[..., Awaitable[TranscriptPage]] | None = None, *, fragments: tuple[TranscriptFragment, ...] | None = None, - budget: PresentationBudget | None = None, committed: bool = True): + budget: PresentationBudget | None = None, committed: bool = True, + prepared: PreparedTranscriptPage | None = None): super().__init__(source_state=LiveTranscript() if committed else ProvisionalTranscript()) self.loader, self.through = loader, page.after self.budget = budget or PresentationBudget( max_items=self.MAX_FRAGMENTS, admission_items=TranscriptPageView.BATCH, ) self.pages = deque([TranscriptPageView( - page, fragments=fragments, batch_size=self.budget.admission_items, + page, fragments=fragments, prepared=prepared, batch_size=self.budget.admission_items, )]) self.older = HistoryEdge("↑ Earlier history loads as you scroll") self.older.tooltip = "Click or press Enter to load earlier history, including in In/out only mode" @@ -353,6 +403,55 @@ def __init__(self, page: TranscriptPage, loader: Callable[..., Awaitable[Transcr self._fragment_budget = self.budget.max_items self.window: HistoryWindow + async def park_document(self) -> PreparedPageSource: + """Transfer original data and admission out of disposable native pages.""" + from toad.work_preparation import retained_bytes + + await self.retire_source(parked=True) + source = self._reader() + projection = self.filter.state.overlay + projected = await projection.park_document() if projection is not None else None + pages = tuple(page.prepared for page in self.pages) + for page in pages: + if not page.retained_bytes: + page.retained_bytes = await source.runtime.run_thread( + retained_bytes, (page.page, page.fragments), + ) + source.park(pages, projected) + return source + + @classmethod + def from_source(cls, source: PreparedPageSource) -> TranscriptHistory: + """Reacquire the original pages; retained source is not live coverage.""" + pages = source.admitted + if not pages or source.closed: + raise ValueError("No retained transcript admission") + history = cls(pages[0].page, source.loader, prepared=pages[0]) + history.through = source.through + history._page_buffer = source + history._source_state = ParkedSourceTranscript(LiveTranscript()) + history.pages.extend(TranscriptPageView( + page.page, prepared=page, batch_size=history.budget.admission_items, + ) for page in pages[1:]) + projected = source.projection_source + if projected is not None: + from toad.transcript_filter import Filtered + projection = ProjectedTranscriptHistory.from_source(projected, owner=history) + history.filter.state = Filtered(projection) + return history + + def acquire_document(self) -> None: + """Mounted consumers have taken custody of the same data resources.""" + if self._page_buffer is not None: + self._page_buffer.release_admission() + if (projection := self.filter.state.overlay) is not None: + projection.acquire_document() + + def resume_source(self) -> None: + super().resume_source() + if (projection := self.filter.state.overlay) is not None: + projection.resume_source() + async def prepare_fragments(self, fragments, current: Callable[[], bool], *, selected=None) -> None: """Prepare this admitted source range before acquiring native custody.""" from toad.widgets.transcript_fragments import TranscriptBodyPreparation @@ -365,6 +464,8 @@ async def prepare_fragments(self, fragments, current: Callable[[], bool], *, sel async def prepare_body(self, current: Callable[[], bool], *, selected=None) -> None: """Warm the actual page admissions, including a restored reader range.""" + if (projection := self.filter.state.overlay) is not None: + await projection.prepare_body(current) for page in self.pages: await self.prepare_fragments(page.fragments[page.start:page.stop], current, selected=selected) @@ -477,6 +578,8 @@ def retained_source_bytes(self) -> int: def compose(self) -> ComposeResult: yield self.older + if (projection := self.filter.state.overlay) is not None: + yield projection for page in self.pages: page.visible_categories = self.selected_categories yield page @@ -611,7 +714,6 @@ def current(): return def _reader(self) -> PreparedPageSource: - assert self.loader is not None reader = self._page_buffer if reader is None or reader.loader is not self.loader or reader.through != self.through: if reader is not None: @@ -717,10 +819,10 @@ async def _publish_latest(self, request: LatestViewportRequest) -> bool: window, loader = snapshot.window, self.loader destination_admission = window.document_viewport.lookahead.admission(self.budget, window.size.height) if loader is None: - page, fragments = self.pages[-1].page, self.pages[-1].fragments + prepared = self.pages[-1].prepared else: prepared = await self._reader().get(PageRequest(before=self.through)) - page, fragments = prepared.page, prepared.fragments + page, fragments = prepared.page, prepared.fragments selected = TranscriptPageView.initial_slice(fragments, destination_admission, True) await self.prepare_fragments( fragments[selected], lambda: snapshot.current(self) and request.current(window), @@ -748,6 +850,7 @@ async def _publish_latest(self, request: LatestViewportRequest) -> bool: try: async with TranscriptPageView.acquire( self, page, fragments=fragments, batch_size=destination_admission, + prepared=prepared, before=self.newer, current=lambda: snapshot.current(self) and request.current(window), ) as view: self.pages = deque([view]) @@ -766,6 +869,7 @@ async def _load_page(self, older: bool) -> None: local = edge.start > 0 if older else edge.stop < len(edge.fragments) page = None fragments = None + prepared = None if not local: if loader is None: return @@ -793,7 +897,7 @@ async def _load_page(self, older: bool) -> None: fallback=edge.fragment_views[0 if older else -1] if edge.fragment_views else edge, ) async with self.window.preserve_history(anchor, root=self): - await self._extend_and_trim(edge, older, local, page, protected, fragments) + await self._extend_and_trim(edge, older, local, page, protected, fragments, prepared) self._require_publication() except _PublicationRetired: return @@ -805,6 +909,7 @@ async def _extend_and_trim( self, edge: TranscriptPageView, older: bool, local: bool, page: TranscriptPage | None, protected: set[Widget], fragments: tuple[TranscriptFragment, ...] | None, + prepared: PreparedTranscriptPage | None = None, ) -> None: # HistoryWindow owns the mutation's native frame publication and # compensated layout; this lock serializes the page's admission. @@ -822,6 +927,7 @@ async def _extend_and_trim( assert fragments is not None async with TranscriptPageView.acquire( self, page, newest=older, fragments=fragments, batch_size=self.budget.admission_items, + prepared=prepared, before=edge if older else self.newer, current=lambda: snapshot.current(self) and edge.capture_admission() == admission, ) as view: @@ -897,19 +1003,32 @@ class ProjectedTranscriptHistory(TranscriptHistory): def __init__(self, owner: TranscriptHistory, source: ProjectedTranscriptSource, prepared: PreparedTranscriptPage) -> None: self._projection_owner = ref(owner) - super().__init__(prepared.page, source.loader, fragments=prepared.fragments, budget=owner.budget) + super().__init__(prepared.page, source.loader, prepared=prepared, budget=owner.budget) self._page_buffer = source # Finish the container's native mount before awaiting row admission. # A slow/held row batch must not strand the page's own message pump in # Compose, where input-settlement barriers would wait on its startup. - self.pages[0].stop = self.pages[0].start + if not source.admitted: + self.pages[0].stop = self.pages[0].start self.add_class("filtered-history-results") + @classmethod + def from_source(cls, source: ProjectedTranscriptSource, *, owner: TranscriptHistory): + if not source.admitted or source.closed: + raise ValueError("No retained projected admission") + history = cls(owner, source, source.admitted[0]) + history.pages.extend(TranscriptPageView( + page.page, prepared=page, batch_size=owner.budget.admission_items, + ) for page in source.admitted[1:]) + history._source_state = ParkedSourceTranscript(LiveTranscript()) + return history + async def _finish_mount(self) -> None: # WorkingTranscript captures the actual window reader. Acquire that # operation only after the shared on_mount has bound native ancestry, # before observers may request another edge from this empty cohort. - self.reserve_source_work() + if self._source_state.accepts_source_work: + self.reserve_source_work() await super()._finish_mount() @property From 5f8952b8b025adc52ce11dd2befd90845740cb65 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 21:06:01 -0400 Subject: [PATCH 04/10] Prepare document selection on workers and copy from retained source --- src/toad/render_tasks.py | 15 +++++++++++++-- src/toad/widgets/prepared_markdown.py | 21 +++++++++++++++++++-- src/toad/widgets/viewport_body.py | 27 ++++++++++++++++++++++----- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/toad/render_tasks.py b/src/toad/render_tasks.py index e15fab2e1..0a7c574cd 100644 --- a/src/toad/render_tasks.py +++ b/src/toad/render_tasks.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING from textual.document._document import DocumentBase from textual.document._wrapped_document import WrappedDocument +from textual.selection import Selection +from textual.style import Style from toad.render_backend import ReusableRenderTask @@ -146,14 +148,23 @@ class MarkdownDocumentRenderTask(ReusableRenderTask["DocumentPaint"]): document: "MarkdownDocument" width: int + root_selection: Selection | None = None + selection_style: Style | None = None + selecting: bool = False def execute(self) -> "DocumentPaint": - return self.document.prepare(self.width) + return self.document.prepare( + self.width, root_selection=self.root_selection, + selection_style=self.selection_style, selecting=self.selecting, + ) def accept_result(self, result: object) -> "DocumentPaint": from textual.document._paint import DocumentPaint - if not isinstance(result, DocumentPaint) or not result.matches(self.document, self.width): + if not isinstance(result, DocumentPaint) or not result.matches( + self.document, self.width, root_selection=self.root_selection, + selection_style=self.selection_style, selecting=self.selecting, + ): raise TypeError("Document renderer returned paint for a different acquisition") return result diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index 7b202c24f..58b395c66 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -15,6 +15,7 @@ from textual.worker import WorkerCancelled from textual.await_complete import AwaitComplete from textual.widget import Widget +from textual.visual import Visual from textual.widgets import Label from textual.widgets._markdown import Markdown, MarkdownBlock @@ -362,7 +363,12 @@ async def _prepare_document(self, document): return MeasuredSourceBody(self._body_measurement.width, self.measured_rows, 1, root_empty=self.is_empty) app = self.app - task = MarkdownDocumentRenderTask(document, width) + selection = self.text_selection + task = MarkdownDocumentRenderTask( + document, width, root_selection=selection, + selection_style=Visual.selection_style(self) if selection is not None else None, + selecting=self.screen._selecting, + ) paint = (await app.render_processes.submit(task) if isinstance(app, ToadApp) else await asyncio.to_thread(task.execute)) cost = (await app.preparation.run_thread(retained_bytes, paint) @@ -372,7 +378,10 @@ async def _prepare_document(self, document): or self._body_measurement.width + self.styles.gutter.width != width): return MeasuredSourceBody(self._body_measurement.width, self.measured_rows, 1, root_empty=self.is_empty) - paint = paint.with_presentation(document) + paint = paint.with_presentation( + document, root_selection=task.root_selection, + selection_style=task.selection_style, selecting=task.selecting, + ) self.loading = False self._table_of_contents = paint.table_of_contents self.post_message(Markdown.TableOfContentsUpdated(self, self.table_of_contents).set_sender(self)) @@ -406,6 +415,14 @@ def notify_style_update(self) -> None: if self.is_attached and self.document is not None and self._body_viewport is None: self.call_later(self.restore_body) + def selection_updated(self, selection) -> None: + super().selection_updated(selection) + # Screen owns selection. Its change retires rows with different paint + # inputs, using the same body writer as source/style/width updates. + self._update_body_measurement(self._body_measurement.style_updated(self)) + if self.is_attached and self.document is not None and self._body_viewport is None: + self.call_later(self.restore_body) + async def _parse_tokens( self, parser: MarkdownIt | _ThreadLocalPathParser, markdown: str, *, use_thread: bool, ) -> list[Token] | None: diff --git a/src/toad/widgets/viewport_body.py b/src/toad/widgets/viewport_body.py index b7356c141..0b4d23e8c 100644 --- a/src/toad/widgets/viewport_body.py +++ b/src/toad/widgets/viewport_body.py @@ -23,6 +23,7 @@ ) from textual.widget import Widget +from textual.visual import Visual from textual.walk import walk_depth_first from textual._measurement import INDEPENDENT_HEIGHT, NATIVE_WIDGET_HEIGHT, height_dependency from textual.geometry import Size @@ -243,7 +244,7 @@ def measured(self, width, rows): return self def get_selection(self, body, selection, select_live): - return select_live(selection) + return None def publication_finished(self, body, worker, result): """A settled resource does not own this worker's publication.""" @@ -316,6 +317,9 @@ def invalidated(self): @dataclass(frozen=True) class LiveBody(MeasuredBody): + def get_selection(self, body, selection, select_live): + return select_live(selection) if self.ready(body) else None + def released(self): return ReleasedBody(self.width, self.rows, self.widgets) @@ -470,7 +474,7 @@ def render(self, body, crop, render_live): return self.previous.render(body, crop, render_live) def get_selection(self, body, selection, select_live): - return self.previous.get_selection(body, selection, select_live) if self.ready(body) else None + return self.previous.get_selection(body, selection, select_live) def _updated(self, previous): return self if previous is self.previous else replace(self, previous=previous) @@ -676,7 +680,12 @@ def render(self, body, crop, render_live): def get_selection(self, body, selection, select_live): # Pointer selection acquires original scene controls before native # targeting. Whole-document copy can borrow each original leaf owner. - if not self.paint_ready(body) or selection.start is not None or selection.end is not None: + # Selection styling may still be preparing; it doesn't replace text. + document = body.document + if (document is None or not self.paint.document.same_source(document) + or self.paint.presentation_key != document.presentation.key + or not document.presentation.current_for(body) + or selection.start is not None or selection.end is not None): return None parts = (leaf.selected_text(selection) for leaf in self.paint.leaves) text = "".join(value[0] + value[1] for value in parts if value is not None).rstrip("\n") @@ -691,9 +700,17 @@ def style_updated(self, body): # Reuse only after full acquired input equality, never by copying # the new participant's invalidation counter onto old pixels. document = body.document.with_presentation(body) - if self.paint.matches(document, self.paint.width): + selection = body.text_selection + if self.paint.matches( + document, self.paint.width, root_selection=selection, + selection_style=Visual.selection_style(body) if selection is not None else None, + selecting=body.screen._selecting, + ): body.document = document - return replace(self, paint=self.paint.with_presentation(document)) + return replace(self, paint=self.paint.with_presentation( + document, root_selection=self.paint.root_selection, + selection_style=self.paint.selection_style, selecting=self.paint.selecting, + )) return self.invalidated() def invalidated(self, width=None): From 30aa663f5e22506b2b9b3bc1d69e17b89308b456 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 21:13:41 -0400 Subject: [PATCH 05/10] Supply retained document paint through its body measurement owner --- src/toad/widgets/viewport_body.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/toad/widgets/viewport_body.py b/src/toad/widgets/viewport_body.py index 0b4d23e8c..285fb8946 100644 --- a/src/toad/widgets/viewport_body.py +++ b/src/toad/widgets/viewport_body.py @@ -143,6 +143,10 @@ def cost(self, body) -> int: ... def paint_bytes(self) -> int: return 0 + @property + def document_paint(self): + return None + @abstractmethod def height(self, body, width, measure) -> int: ... @@ -467,6 +471,10 @@ def cost(self, body): def paint_bytes(self): return self.previous.paint_bytes + @property + def document_paint(self): + return self.previous.document_paint + def height(self, body, width, measure): return self.previous.height(body, width, measure) @@ -633,6 +641,10 @@ class PreparedDocumentBody(BodyMeasurement): paint: "DocumentPaint" resource_bytes: int + @property + def document_paint(self): + return self.paint + @property def width(self): return self.paint.content_size.width From d78816544e91cfbb3e22dbe9d700e816a65228ab Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 21:26:14 -0400 Subject: [PATCH 06/10] Compose prepared Markdown capability without masking native constructors --- src/toad/widgets/prepared_markdown.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index 58b395c66..2d68757f0 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -149,7 +149,7 @@ def trim(self, count: int, *, older: bool) -> None: self.remove_children(retired) -class PreparedMarkdownContent(WorkerStatic): +class PreparedMarkdownContent: """Native blocks keep token/link custody; WorkerStatic owns their wrapping.""" @classmethod @@ -181,35 +181,35 @@ async def preparation_publication(self, *, layout: bool): yield -class PreparedParagraph(ConversationMarkdown.BLOCKS["paragraph_open"], PreparedMarkdownContent): +class PreparedParagraph(PreparedMarkdownContent, ConversationMarkdown.BLOCKS["paragraph_open"], WorkerStatic): pass -class PreparedH1(ConversationMarkdown.BLOCKS["h1"], PreparedMarkdownContent): +class PreparedH1(PreparedMarkdownContent, ConversationMarkdown.BLOCKS["h1"], WorkerStatic): pass -class PreparedH2(ConversationMarkdown.BLOCKS["h2"], PreparedMarkdownContent): +class PreparedH2(PreparedMarkdownContent, ConversationMarkdown.BLOCKS["h2"], WorkerStatic): pass -class PreparedH3(ConversationMarkdown.BLOCKS["h3"], PreparedMarkdownContent): +class PreparedH3(PreparedMarkdownContent, ConversationMarkdown.BLOCKS["h3"], WorkerStatic): pass -class PreparedH4(ConversationMarkdown.BLOCKS["h4"], PreparedMarkdownContent): +class PreparedH4(PreparedMarkdownContent, ConversationMarkdown.BLOCKS["h4"], WorkerStatic): pass -class PreparedH5(ConversationMarkdown.BLOCKS["h5"], PreparedMarkdownContent): +class PreparedH5(PreparedMarkdownContent, ConversationMarkdown.BLOCKS["h5"], WorkerStatic): pass -class PreparedH6(ConversationMarkdown.BLOCKS["h6"], PreparedMarkdownContent): +class PreparedH6(PreparedMarkdownContent, ConversationMarkdown.BLOCKS["h6"], WorkerStatic): pass -class PreparedCodeLabel(Label, PreparedMarkdownContent): +class PreparedCodeLabel(PreparedMarkdownContent, Label, WorkerStatic): """Code uses the same native Content worker and publication lifetime.""" From e9f73302b950a5e2b1cfb4dca416969fa2bd8511 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 21:34:58 -0400 Subject: [PATCH 07/10] Prepare nested visible bodies through the original frame cohort --- src/toad/widgets/viewport_body.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/toad/widgets/viewport_body.py b/src/toad/widgets/viewport_body.py index 285fb8946..5e68a2cf8 100644 --- a/src/toad/widgets/viewport_body.py +++ b/src/toad/widgets/viewport_body.py @@ -1544,7 +1544,14 @@ async def _reconcile(self) -> None: owners = tuple(self.body_roots()) required = tuple(owner for owner in owners if self.requires_body(owner, visible=visible, protected=protected)) - for owner in required: + # Warm admission and retirement belong to outer resources. + # Publication also needs nested bodies in the original visible + # cohort; a ready outer fragment cannot prepare their rows. + foreground = tuple(dict.fromkeys(( + *required, + *(body for _window, body in screen.viewport_presentation.visible_bodies((self.window,))), + ))) + for owner in foreground: owner.require_native_body() # Reuse the same body admission and worker. Restore only the # neighboring destination bodies, not every skipped record. @@ -1570,7 +1577,7 @@ async def _reconcile(self) -> None: # One foreground cohort produces readiness before frame # admission. Per-body paint waits would hold this worker while # the remaining visible dormant bodies reject that same frame. - restoring = tuple(owner for owner in required + restoring = tuple(owner for owner in foreground if owner.is_attached and not owner._closing and owner.body_dormant and not owner.body_ready) restored = () if restoring: @@ -1585,7 +1592,7 @@ async def _reconcile(self) -> None: # retiring or predicting from the preceding scene. if self._pending: continue - if any(not owner.body_ready for owner in required): + if any(not owner.body_ready for owner in foreground): # Exposed source owns this frame first. Its preparation # and native publication already request reconciliation; # do not fill their renderer with hidden capture work. @@ -1599,7 +1606,7 @@ async def _reconcile(self) -> None: # preparation needed to make the same frame publishable. async with asyncio.TaskGroup() as source_preparation: source_tasks = [source_preparation.create_task(owner.prepare_visible_source()) - for owner in required] + for owner in foreground] if any(task.result() for task in source_tasks): screen.frame_presentation.defer(self.window, self.request) return From 377aa7f2416f4c1fc39db40739d6998dd98f7269 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 22:16:12 -0400 Subject: [PATCH 08/10] Retain requested Markdown source and navigate acquired grammar roots --- src/toad/block_content.py | 42 ++- src/toad/block_navigation.py | 355 ++++++++++++++++++++++--- src/toad/conversation_markdown.py | 10 +- src/toad/widgets/conversation.py | 75 +++--- src/toad/widgets/menu.py | 8 +- src/toad/widgets/prepared_markdown.py | 92 +++++-- src/toad/widgets/streaming_markdown.py | 23 +- tests/test_t4_turn_navigation.py | 123 ++++++++- 8 files changed, 607 insertions(+), 121 deletions(-) diff --git a/src/toad/block_content.py b/src/toad/block_content.py index 79947ccf6..8672c5c4f 100644 --- a/src/toad/block_content.py +++ b/src/toad/block_content.py @@ -1,5 +1,6 @@ -"""Nominal selection capabilities on the actual mounted block widgets.""" +"""Declared block capabilities shared by scene and original source consumers.""" from collections.abc import Iterable +from functools import cached_property from textual.widgets import _markdown as native_markdown @@ -9,6 +10,12 @@ class BlockContent: """Non-interactive blocks inherit empty content and expansion behavior.""" + @cached_property + def block_cursor(self): + from toad.block_navigation import AtomicBlockCursor + + return AtomicBlockCursor(self) + def get_block_menu(self) -> Iterable[MenuItem]: return () @@ -34,8 +41,37 @@ def collapse_block(self) -> None: class MarkdownBlockContent(BlockContent): """Declared native blocks share source-copy and menu capabilities.""" - def get_clipboard_text(self) -> str: - return self.source + code: str | None = None + + @classmethod + def clipboard_source(cls, markdown: str | None, code: str | None) -> str | None: + return markdown + + @classmethod + def prompt_source(cls, markdown: str | None, code: str | None) -> str | None: + return cls.clipboard_source(markdown, code) + + @classmethod + def source_block_menu(cls, source) -> Iterable[MenuItem]: + # A scene menu may depend on actual controls. It must explicitly + # declare source behavior before detached consumers can supply it. + cls._require_document_methods({"get_block_menu": BlockContent.get_block_menu}) + return () + + @classmethod + def source_copy(cls, source, *, prompt: bool = False) -> str | None: + cls._require_document_methods({ + "get_clipboard_text": MarkdownBlockContent.get_clipboard_text, + "get_prompt_text": MarkdownBlockContent.get_prompt_text, + }) + supply = cls.prompt_source if prompt else cls.clipboard_source + return supply(source.source_text(), source.code) + + def get_clipboard_text(self) -> str | None: + return self.clipboard_source(self.source, self.code) + + def get_prompt_text(self) -> str | None: + return self.prompt_source(self.source, self.code) @classmethod def blocks_for(cls, catalog: dict[str, type]) -> dict[str, type]: diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 0ad62b97c..298b64b44 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -1,10 +1,17 @@ """Blocks declare cursor behavior; one component owns content selection.""" +from __future__ import annotations + from abc import abstractmethod from functools import cached_property +from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily +from textual.geometry import Region from textual.widget import Widget from toad.block_content import BlockContent +if TYPE_CHECKING: + from textual.document._markdown import MarkdownSourceBlock + class CursorDirection(DeclaredFamily, affix="Cursor"): @property @@ -35,16 +42,56 @@ def __init__(self, block): @property @abstractmethod - def selected(self) -> Widget | None: ... + def selected(self) -> Widget | MarkdownSourceBlock | None: ... @abstractmethod - def enter(self, direction: CursorDirection) -> None: ... + def enter(self, direction: CursorDirection) -> bool: ... @abstractmethod - def move(self, direction: CursorDirection) -> Widget | None: ... + def move(self, direction: CursorDirection) -> bool: ... @abstractmethod - def select(self, widget: Widget) -> None: ... + def select(self, widget: Widget) -> bool: ... + + @property + def active(self) -> BlockCursor | None: + return self if self.selected is not None else None + + def accepts(self, owner) -> bool: + return owner is self.selected + + def clear(self) -> None: + pass + + def get_clipboard_text(self) -> str | None: + return None + + def get_prompt_text(self) -> str | None: + return self.get_clipboard_text() + + def get_block_menu(self): + return () + + @property + def allow_maximize(self) -> bool: + return False + + def maximize(self, screen) -> None: + pass + + @property + def region(self) -> Region | None: + return None + + @property + def visible_region(self) -> Region | None: + return None + + def scroll_to_center(self, window) -> None: + pass + + def export_render(self): + return None class AtomicBlockCursor(BlockCursor): @@ -53,34 +100,261 @@ def selected(self): return self.block def enter(self, direction): - pass + return True def move(self, direction): - return None + return False def select(self, widget): - pass + return widget is self.block or self.block in widget.ancestors + + def get_clipboard_text(self): + return self.block.get_clipboard_text() + + def get_prompt_text(self): + return self.block.get_prompt_text() + + def get_block_menu(self): + return self.block.get_block_menu() + + @property + def allow_maximize(self): + return self.block.allow_maximize + + def maximize(self, screen): + screen.maximize(self.block, container=False) + self.block.focus() + + @property + def region(self): + return self.block.region + + @property + def visible_region(self): + geometry = self.block.screen._compositor.visible_widgets.get(self.block) + return None if geometry is None else geometry[0] + + def scroll_to_center(self, window): + window.scroll_to_center(self.block, immediate=True) + + def export_render(self): + from textual._compositor import Compositor + + compositor = Compositor() + compositor.reflow(self.block, self.block.outer_size) + return self.block.outer_size, compositor.render_full_update() class ChildBlockCursor(BlockCursor): def __init__(self, block): super().__init__(block) - self.index = -1 + self._child = None + + @property + def current(self): + return self._child if self._child in self.block.displayed_children else None + + @property + def active(self): + return self.current.block_cursor.active if self.current is not None else None @property def selected(self): + cursor = self.active + return None if cursor is None else cursor.selected + + def _enter_from(self, index, direction): children = self.block.displayed_children - return children[self.index] if 0 <= self.index < len(children) else None + while 0 <= index < len(children): + self._child = children[index] + # An entry awaiting the original producer is still this child. + # Only a completed empty admission permits crossing its boundary. + if self._child.block_cursor.enter(direction): + return True + index += direction.step + self._child = None + return False def enter(self, direction): - self.index = direction.entry(len(self.block.displayed_children)) + return self._enter_from(direction.entry(len(self.block.displayed_children)), direction) def move(self, direction): - self.index += direction.step - return self.selected + child = self.current + if child is None: + return False + if child.block_cursor.move(direction): + return True + index = self.block.displayed_children.index(child) + direction.step + return self._enter_from(index, direction) def select(self, widget): - self.index = self.block.displayed_children.index(widget) + for child in self.block.displayed_children: + if child.block_cursor.select(widget): + self._child = child + return True + return False + + def clear(self): + if self.current is not None: + self.current.block_cursor.clear() + self._child = None + + +class DocumentBlockCursor(BlockCursor): + """Select original grammar roots; native paint supplies their placement. + + The ordinal is a position in that exact source cohort, not a token ID. + Source identity fences it before a new presentation's roots may supply + geometry. Neither paint nor a second root collection is retained here. + """ + + def __init__(self, block): + super().__init__(block) + self._selection: tuple[int, MarkdownSourceBlock] | CursorDirection | None = None + + @property + def selected(self): + if self._selection is None or self.block._closing: + return None + if isinstance(self._selection, CursorDirection): + paint = self._current_paint() + if paint is None: + return None + direction = self._selection + if not self._enter_from(paint, direction.entry(len(paint.roots)), direction): + return None + index, original = self._selection + document = self.block.document + if (self.block._pending_source != original.document.source + or document is not None and not original.document.same_source(document)): + self._selection = None + return None + # A selected source outlives rows and native controls. Reconstruction + # may borrow it, but only admitted paint can replace its placement. + paint = self._current_paint() + if paint is None: + return original + if not original.document.same_source(paint.document) or not 0 <= index < len(paint.roots): + self._selection = None + return None + source = paint.roots[index] + if source.source_index != original.source_index or source.placement is None: + self._selection = None + return None + self._selection = index, source + return source + + @property + def active(self): + source = self.selected + return self if source is not None or isinstance(self._selection, CursorDirection) else None + + def clear(self): + self._selection = None + + def _current_paint(self): + paint = self.block.block_document_paint + width = self.block._body_measurement.width + self.block.styles.gutter.width + return paint if paint is not None and paint.is_current(self.block, width) else None + + def _enter_from(self, paint, index, direction): + while 0 <= index < len(paint.roots): + source = paint.roots[index] + # Native displayed-child admission excludes CSS-hidden/unplaced + # roots. The original producer owns that answer, not paint leaves. + if source.placement is not None: + self._selection = index, source + return True + index += direction.step + self._selection = None + return False + + def enter(self, direction): + if self.block._closing: + return False + paint = self._current_paint() + if paint is None: + # Retain the original entry, not a guessed root from stale paint. + # Source requests revoke it through this cursor's clear(). + self._selection = direction + return True + return self._enter_from(paint, direction.entry(len(paint.roots)), direction) + + def move(self, direction): + source = self.selected + if source is None: + return isinstance(self._selection, CursorDirection) + paint = self._current_paint() + if paint is None: + # Copy retains the original selected source while new paint + # prepares. It does not admit another root through stale geometry. + return True + index = self._selection[0] + direction.step + return self._enter_from(paint, index, direction) + + def select(self, widget): + # Pointer dispatch acquires actual native controls first. This source + # cursor never fabricates a widget or guesses a root from paint leaves. + return False + + def accepts(self, owner): + source = self.selected + return (source is not None and isinstance(owner, type(source)) + and source.source_index == owner.source_index + and source.document.same_source(owner.document)) + + def get_clipboard_text(self): + source = self.selected + return None if source is None else source.declaration.source_copy(source) + + def get_prompt_text(self): + source = self.selected + return None if source is None else source.declaration.source_copy(source, prompt=True) + + def get_block_menu(self): + source = self.selected + return () if source is None else source.declaration.source_block_menu(source) + + def _placement(self): + source = self.selected + paint = self._current_paint() + if source is None or paint is None: + return None + return source.placement + + @property + def region(self): + placement = self._placement() + return None if placement is None else placement.region.translate(self.block.region.offset) + + @property + def visible_region(self): + placement = self._placement() + geometry = self.block.screen._compositor.visible_widgets.get(self.block) + if placement is None or geometry is None: + return None + region, clip = geometry + return placement.region.intersection(placement.clip).translate(region.offset).intersection(clip) + + def scroll_to_center(self, window): + region = self.region + if region is not None: + window.scroll_to_region( + region.translate(window.scroll_offset - window.scrollable_content_region.offset), + center=True, immediate=True, + ) + + def export_render(self): + from textual.strip import StripRenderable + + placement = self._placement() + if placement is None: + return None + paint = self.block.block_document_paint + region = placement.region.intersection(paint.size.region) + if not region: + return None + return region.size, StripRenderable(paint.render_lines(region), region.width) class ConversationBlock(BlockContent): @@ -93,7 +367,7 @@ def block_cursor(self) -> BlockCursor: class ContentNavigation: def __init__(self, contents): self.contents = contents - self.index = -1 + self._current = None @property def blocks(self): @@ -101,47 +375,52 @@ def blocks(self): @property def current(self) -> ConversationBlock | None: - return self.blocks[self.index] if 0 <= self.index < len(self.blocks) else None + return self._current if self._current in self.blocks else None + + def clear(self) -> None: + self._current = None + + @property + def cursor(self) -> BlockCursor | None: + return self.current.block_cursor.active if self.current is not None else None @property def selected(self): - return self.current.block_cursor.selected if self.current is not None else None + cursor = self.cursor + return None if cursor is None else cursor.selected def move(self, direction: CursorDirection) -> None: blocks = self.blocks if not blocks: - self.index = -1 + self.clear() return - if self.index == -1: + current = self.current + if current is None: if not direction.enters_from_prompt: return - self.index = direction.entry(len(blocks)) + destination = direction.entry(len(blocks)) else: - if self.current is not None and self.current.block_cursor.move(direction) is not None: + if current.block_cursor.move(direction): return - destination = self.index + direction.step - if destination < 0: - self.current.block_cursor.enter(direction) + destination = blocks.index(current) + direction.step + while 0 <= destination < len(blocks): + block = blocks[destination] + if block.block_cursor.enter(direction): + self._current = block return - if destination >= len(blocks): - self.index = -1 - return - self.index = destination - self.current.block_cursor.enter(direction) + destination += direction.step + if destination < 0 and current is not None: + # At the oldest admitted block, Up clamps to its first source + # root. Entering from Up would wrap back to its last root. + current.block_cursor.enter(DownCursor()) + else: + self.clear() def select(self, widget: Widget) -> bool: # Textual supplies an arbitrary descendant once at the click boundary. for block in self.blocks: - child = widget - while child is not block and child.parent is not None: - child = child.parent - if child is block: - self.index = self.blocks.index(block) - if widget is not block: - direct = widget - while direct.parent is not block: - direct = direct.parent - block.block_cursor.select(direct) + if block.block_cursor.select(widget): + self._current = block return True return False diff --git a/src/toad/conversation_markdown.py b/src/toad/conversation_markdown.py index a9415c6b9..63d8c7db5 100644 --- a/src/toad/conversation_markdown.py +++ b/src/toad/conversation_markdown.py @@ -19,11 +19,13 @@ class ConversationCodeFence(MarkdownFence): - def get_clipboard_text(self) -> str: - return self._content.plain + @classmethod + def clipboard_source(cls, markdown: str | None, code: str | None) -> str | None: + return code - def get_prompt_text(self) -> str: - return self.source + @classmethod + def prompt_source(cls, markdown: str | None, code: str | None) -> str | None: + return markdown _PATH_PATTERN = re.compile( diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c5fe515ed..d0aae5112 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -57,7 +57,6 @@ from textual.strip import Strip from textual.widget import Widget from textual.widgets import Static -from textual.widgets.markdown import MarkdownBlock from toad import jsonrpc, messages, paths @@ -123,6 +122,7 @@ def make_session_title(prompt: str) -> str: if TYPE_CHECKING: + from textual.document._markdown import MarkdownSourceBlock from toad.widgets.agent_response import AgentResponse from toad.widgets.question import Ask from toad.widgets.terminal import Terminal @@ -313,9 +313,8 @@ def render_lines(self, crop: Region) -> list[Strip]: # Selection belongs to ContentNavigation. The published scene supplies # its current position, including nested and lazily replaced bodies. visible = self.screen._compositor.visible_widgets - selected = self.query_ancestor(Conversation).navigation.selected - if (geometry := visible.get(selected)) is not None: - region, _clip = geometry + cursor = self.query_ancestor(Conversation).navigation.cursor + if cursor is not None and (region := cursor.visible_region) is not None: origin = visible[self][0].y + crop.y style = self.get_component_rich_style( "cursor--selected-blink" if self.blink else "cursor--selected" @@ -387,7 +386,6 @@ def _turn_changed(self, owner: TurnOwner) -> None: working_directory: var[str] = var("") - _blocks: var[list[MarkdownBlock] | None] = var(None) _shell: var[Shell | None] = var(None) @@ -682,7 +680,7 @@ def watch_visible_categories( current = ReaderPosition.capture(window) self._filter_reader_positions[previous] = current position = self._filter_reader_positions.get(selected, current) - self.navigation.index = -1 + self.navigation.clear() self.screen.clear_selection() for block in self.contents.children: apply_block_filter(block, selected) @@ -861,7 +859,7 @@ def add_focusable_terminal(self, terminal: Terminal) -> None: async def on_shell_terminal_terminate(self, event: ShellTerminal.Terminate) -> None: if not event.teminal.is_finalized: await self.shell.interrupt() - self.navigation.index = -1 + self.navigation.clear() self.flash("Command interrupted", style="success") @handles(DirectoryChanged) @@ -953,7 +951,7 @@ def cursor_block(self) -> ConversationBlock | None: return self.navigation.current @property - def cursor_block_child(self) -> Widget | None: + def cursor_block_child(self) -> Widget | MarkdownSourceBlock | None: return self.navigation.selected async def _read_thread_activity(self): @@ -1278,9 +1276,13 @@ async def agent_turn_over(self, stop_reason: type[StopReason] | None) -> None: @on(Menu.OptionSelected) async def on_menu_option_selected(self, event: Menu.OptionSelected) -> None: + from toad.screens.session_view import SessionView + event.stop() event.menu.display = False - if event.action is not None: + cursor = self.navigation.cursor + if (self.query_ancestor(SessionView).is_current and event.action is not None + and cursor is not None and cursor.accepts(event.owner)): await self.run_action(event.action, {"block": event.owner}) if (cursor_block := self.cursor_block_child) is not None: self.call_after_refresh(self.cursor.refresh) @@ -1972,7 +1974,7 @@ async def prune_window(self, low_mark: int, high_mark: int) -> None: break prune_children.append(child) - self.navigation.index = -1 + self.navigation.clear() self.cursor.refresh() contents.refresh(layout=True) @@ -2089,7 +2091,7 @@ def focus_prompt(self, reset_cursor: bool = True, scroll_end: bool = True) -> No scroll_end: Scroll t the end of the content. """ if reset_cursor: - self.navigation.index = -1 + self.navigation.clear() self.cursor.refresh() if scroll_end: self.jump_to_latest() @@ -2100,7 +2102,8 @@ def jump_to_latest(self) -> None: self.transcript.request() async def action_select_block(self) -> None: - if (block := self.cursor_block_child) is None: + cursor = self.navigation.cursor + if cursor is None or (block := cursor.selected) is None: return menu_options = [ @@ -2109,46 +2112,44 @@ async def action_select_block(self) -> None: MenuItem("Open as S[u]V[/]G", "export_to_svg", "v"), ] - if block.allow_maximize: + if cursor.allow_maximize: menu_options.append(MenuItem("[u]M[/u]aximize", "maximize_block", "m")) - menu_options.extend(block.get_block_menu()) + menu_options.extend(cursor.get_block_menu()) menu = Menu(block, menu_options) - menu.offset = Offset(1, block.region.offset.y) + region = cursor.region + menu.offset = Offset(1, region.y if region is not None else self.window.region.y) await self.mount(menu) menu.focus() def action_copy_to_clipboard(self) -> None: - block = self.cursor_block_child - if block is not None and (text := block.get_clipboard_text()): + cursor = self.navigation.cursor + if cursor is not None and (text := cursor.get_clipboard_text()): self.app.copy_to_clipboard(text) self.flash("Copied to clipboard") def action_copy_to_prompt(self) -> None: - block = self.cursor_block_child - if block is not None and (text := block.get_prompt_text()): + cursor = self.navigation.cursor + if cursor is not None and (text := cursor.get_prompt_text()): self.prompt.append(text) self.flash("Copied to prompt") self.focus_prompt() def action_maximize_block(self) -> None: - if (block := self.cursor_block_child) is not None: - self.screen.maximize(block, container=False) - block.focus() + cursor = self.navigation.cursor + if cursor is not None and cursor.allow_maximize: + cursor.maximize(self.screen) def action_export_to_svg(self) -> None: - block = self.cursor_block_child - if block is None: + cursor = self.navigation.cursor + if cursor is None or (export := cursor.export_render()) is None: return import platformdirs - from textual._compositor import Compositor from textual._files import generate_datetime_filename - width, height = block.outer_size - compositor = Compositor() - compositor.reflow(block, block.outer_size) - render = compositor.render_full_update() + size, render = export + width, height = size import io import os.path @@ -2176,17 +2177,29 @@ def action_export_to_svg(self) -> None: def refresh_block_cursor(self) -> None: self.cursor.refresh() - if (cursor_block := self.cursor_block_child) is not None: + if (cursor := self.navigation.cursor) is not None: # Resolve this navigation event before a later tab/editor event. self.screen.set_focus(self.window) self.call_after_refresh( - self.window.scroll_to_center, cursor_block, immediate=True + self._present_block_cursor, cursor ) else: self.window.anchor() self.prompt.focus() self.refresh_bindings() + def _present_block_cursor(self, cursor) -> None: + from toad.screens.session_view import SessionView + + # Original frame admission resolves pending source entry. A later + # prompt/tab/source event may already have revoked this cursor intent. + if (self.is_attached and not self._closing + and self.query_ancestor(SessionView).is_current + and self.navigation.cursor is cursor): + cursor.scroll_to_center(self.window) + self.cursor.refresh() + self.refresh_bindings() + class ConversationCommsConsumer(MroDispatch): def __init__(self, conversation, message): self.conversation = conversation diff --git a/src/toad/widgets/menu.py b/src/toad/widgets/menu.py index 777cc035d..b2c6336c4 100644 --- a/src/toad/widgets/menu.py +++ b/src/toad/widgets/menu.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING from textual import on from textual.app import ComposeResult @@ -14,6 +15,9 @@ from toad.menus import MenuItem from toad.widgets.selection import SelectionListItem +if TYPE_CHECKING: + from textual.document._markdown import MarkdownSourceBlock + class NonSelectableLabel(Label): ALLOW_SELECT = False @@ -93,7 +97,7 @@ class OptionSelected(Message): """The user selected on of the options.""" menu: Menu - owner: Widget + owner: Widget | MarkdownSourceBlock action: str | None @dataclass @@ -102,7 +106,7 @@ class Dismissed(Message): menu: Menu - def __init__(self, owner: Widget, options: list[MenuItem], *args, **kwargs) -> None: + def __init__(self, owner: Widget | MarkdownSourceBlock, options: list[MenuItem], *args, **kwargs) -> None: self._owner = owner self._options = options super().__init__(*args, **kwargs) diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index 2d68757f0..608b89b19 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -3,7 +3,7 @@ import asyncio from contextlib import ExitStack, asynccontextmanager from collections.abc import Callable -from functools import partial +from functools import cached_property, partial from typing import cast from markdown_it import MarkdownIt @@ -23,6 +23,7 @@ from toad.conversation_markdown import ConversationCodeFence, ConversationMarkdown, _ThreadLocalPathParser from toad.markdown_preparation import PreparedMarkdown, PreparedMarkdownPart from toad.block_content import MarkdownBlockContent +from toad.block_navigation import ChildBlockCursor, DocumentBlockCursor from toad.layout import trim_trailing_margin from toad.render_tasks import MarkdownRenderTask, MarkdownDocumentRenderTask from toad.work_preparation import retained_bytes @@ -253,6 +254,38 @@ class PreparedConversationMarkdown(MarkdownBlockContent, MeasuredViewportBody, C "fence": PreparedCodeFence, "code_block": PreparedCodeFence, } + @cached_property + def _document_block_cursor(self): + return DocumentBlockCursor(self) + + @cached_property + def _native_block_cursor(self): + return ChildBlockCursor(self) + + @property + def block_cursor(self): + # Renderer retirement/acquisition does not change source selection. + # Only a custom grammar retains its original scene cursor contract. + if self.partitionable_syntax: + return self._document_block_cursor + return self._native_block_cursor + + @property + def block_document_paint(self): + """Borrow the body's original resource without extending paint custody. + + Source-copy admission is distinct from selected-row/style readiness. + A preceding publication can still supply this source while its new + presentation prepares, but never a replacement source or detached view. + """ + paint = self._body_measurement.document_paint + document = self.document + if (not self.is_attached or self._closing or paint is None or document is None + or document.source != self.source + or not paint.document.same_source(document)): + return None + return paint + def native_body_ready(self) -> bool: return super().native_body_ready() and not self.loading @@ -272,6 +305,9 @@ def __init__( self._prepared_markdown: PreparedMarkdown | None = None self.document = None self._markdown_part = markdown_part + # Requested text survives pending publication, cancellation and scene + # retirement. Native .source describes the text its scene consumed. + self._pending_source = markdown or "" factory = self._make_parser if parser_factory is None else parser_factory super().__init__(markdown, name=name, id=id, classes=classes, parser_factory=factory, open_links=open_links) @@ -286,7 +322,9 @@ def partitionable_syntax(self) -> bool: """ return getattr(self._parser_factory, "__func__", None) is ConversationMarkdown._make_parser - def acquired_source(self, markdown: str) -> str | PreparedMarkdownPart: + def acquired_source(self, markdown: str | PreparedMarkdownPart) -> str | PreparedMarkdownPart: + if isinstance(markdown, PreparedMarkdownPart): + return markdown part = self._markdown_part return part if part is not None and part.text == markdown else markdown @@ -303,7 +341,8 @@ def _initialize_document(self, markdown: str | None) -> AwaitComplete: # content. Observe that original receipt after completion; waiting on # this pump would hold wheel delivery behind background preparation. # Native source consumption and TOC completion remain unchanged. - super()._initialize_document(markdown).call_when_ready(self) + initial = None if markdown is None and not self._pending_source else self._pending_source + super()._initialize_document(initial).call_when_ready(self) return AwaitComplete.nothing() def retire_body_resources(self) -> None: @@ -312,16 +351,17 @@ def retire_body_resources(self) -> None: self.document = None async def materialize_native_body(self): - if self.document is not None and self.document.source == self.source: + source = self._pending_source + if self.document is not None and self.document.source == source: # Width/style demand changes presentation, not parser or resolved # link acquisition. Borrow this original delivered source resource. return await self._prepare_document(self.document.with_presentation(self)) - return await self._update_body_source(self.source) + return await self._update_body_source(self.acquired_source(source)) async def materialize_interactive_body(self): # The original pointer owner acquires actual controls before choosing # a receiver. It never dispatches a synthetic click against paint. - await self.publish_body(partial(ConversationMarkdown.update, self, self.source)) + await self.publish_body(partial(ConversationMarkdown.update, self, self._pending_source)) @classmethod def document_root(cls, document, children): @@ -334,14 +374,30 @@ def document_root(cls, document, children): return cls.native_document_root(document, children) def update(self, markdown: str) -> AwaitComplete: - return self.publish_body(partial(self._update_body_source, markdown)) + source = self._request_source(markdown, append=False) + return self.publish_body(partial(self._update_body_source, self.acquired_source(source))) def append(self, markdown: str) -> AwaitComplete: - return self.publish_body(partial(self._append_body_source, markdown)) + source = self._request_source(markdown, append=True) + return self.publish_body(partial(self._update_body_source, self.acquired_source(source))) + + def _request_source(self, markdown: str, *, append: bool) -> str: + """Accept a source request once, before its publication can await. - async def _update_body_source(self, markdown: str): - if self._markdown_part is not None and self._markdown_part.text != markdown: - self._markdown_part = None + A worker's cancellation or older snapshot cannot revoke this request + or discard syntax acquired for a newer one. The existing body writer + orders publication; reconstruction borrows the current requested text. + """ + if not self._closing: + if not append: + self.block_cursor.clear() + self._pending_source = self._pending_source + markdown if append else markdown + if self._markdown_part is not None and self._markdown_part.text != self._pending_source: + self._markdown_part = None + return self._pending_source + + async def _update_body_source(self, source: str | PreparedMarkdownPart): + markdown = source.text if isinstance(source, PreparedMarkdownPart) else source if not self.partitionable_syntax: # An arbitrary parser/factory hasn't declared the conversation # source contract. Its original scene producer still owns blocks, @@ -350,7 +406,7 @@ async def _update_body_source(self, markdown: str): self._markdown = markdown parser = self._parser_factory() async with self.lock: - tokens = await self._parse_tokens(parser, markdown, use_thread=True) + tokens = await self._parse_tokens(parser, source, use_thread=True) if tokens is None: return False document = self.acquire_document(markdown, tokens) @@ -397,11 +453,6 @@ def goto_anchor(self, anchor: str) -> bool: return True return super().goto_anchor(anchor) - async def _append_body_source(self, markdown: str): - if self._markdown_part is not None and self._markdown_part.text != self.source + markdown: - self._markdown_part = None - return await self._update_body_source(self.source + markdown) - def on_unmount(self) -> None: self._prepared_markdown = None self.document = None @@ -424,12 +475,13 @@ def selection_updated(self, selection) -> None: self.call_later(self.restore_body) async def _parse_tokens( - self, parser: MarkdownIt | _ThreadLocalPathParser, markdown: str, *, use_thread: bool, + self, parser: MarkdownIt | _ThreadLocalPathParser, markdown: str | PreparedMarkdownPart, *, use_thread: bool, ) -> list[Token] | None: # Custom parser factories retain their native semantics. This process # function implements the declaration-owned conversation/path parser. parent = self.parent if type(parser) is not _ThreadLocalPathParser: + markdown = markdown.text if isinstance(markdown, PreparedMarkdownPart) else markdown tokens = await super()._parse_tokens(parser, markdown, use_thread=use_thread) if tokens is not None: prepared = await asyncio.to_thread( @@ -440,7 +492,7 @@ async def _parse_tokens( self._prepared_markdown = prepared return tokens if not isinstance(self.app, ToadApp): - source = self.acquired_source(markdown) + source = markdown def acquire_local(): tokens = (parser.resolve_tokens(source.acquire_tokens()) if isinstance(source, PreparedMarkdownPart) else parser.parse(source)) @@ -457,7 +509,7 @@ def acquire_local(): # after highlighting; they never become reusable renderer inputs. while not self._closing and self.is_attached and not self._pruning: theme = (self.app.native_ansi_color, self.app.current_theme.dark) - request = self.app.render_processes.submit(MarkdownRenderTask(self.acquired_source(markdown), *theme)) + request = self.app.render_processes.submit(MarkdownRenderTask(markdown, *theme)) worker = self.run_worker(request, group="markdown-preparation", exit_on_error=False) try: prepared = await worker.wait() diff --git a/src/toad/widgets/streaming_markdown.py b/src/toad/widgets/streaming_markdown.py index 2a889c069..207eb4b96 100644 --- a/src/toad/widgets/streaming_markdown.py +++ b/src/toad/widgets/streaming_markdown.py @@ -35,7 +35,6 @@ def __init__(self, markdown: str | None = None, *, paginate: bool = True, self.budget = PresentationBudget() self._content_lock = asyncio.Lock() self._content_generation = 0 - self._pending_source: str | None = None self._needs_full_markdown_update = False def compose(self) -> ComposeResult: @@ -105,7 +104,7 @@ async def _prepare_parts(self, parts, current: Callable[[], bool]) -> bool: return current() async def materialize_native_body(self) -> None: - await self._update_content(self.source, append=False) + await self._update_content(self.acquired_source(self._pending_source), "", append=False) async def materialize_interactive_body(self): # This source range owns real prefix/disclosure widgets. Its document @@ -132,13 +131,15 @@ def retire_body_resources(self) -> None: super().retire_body_resources() def update(self, markdown: str) -> AwaitComplete: - return self.publish_body(partial(self._update_content, markdown, append=False)) + source = self._request_source(markdown, append=False) + return self.publish_body(partial(self._update_content, self.acquired_source(source), markdown, append=False)) def append(self, markdown: str) -> AwaitComplete: - return self.publish_body(partial(self._update_content, markdown, append=True)) + source = self._request_source(markdown, append=True) + return self.publish_body(partial(self._update_content, self.acquired_source(source), markdown, append=True)) async def _parse_tokens( - self, parser: MarkdownIt | _ThreadLocalPathParser, markdown: str, *, use_thread: bool, + self, parser: MarkdownIt | _ThreadLocalPathParser, markdown: str | PreparedMarkdownPart, *, use_thread: bool, ) -> list[Token] | None: generation = self._content_generation tokens = await super()._parse_tokens(parser, markdown, use_thread=use_thread) @@ -147,14 +148,11 @@ async def _parse_tokens( return None return tokens - async def _update_content(self, text: str, *, append: bool) -> None: + async def _update_content(self, source: str | PreparedMarkdownPart, text: str, *, append: bool) -> None: if self._closing: return self._content_generation += 1 generation = self._content_generation - previous_source = self.source if self._pending_source is None else self._pending_source - source = previous_source + text if append else text - self._pending_source = source parent = self.parent def is_current() -> bool: @@ -166,11 +164,11 @@ def is_current() -> bool: except asyncio.CancelledError: if generation == self._content_generation: self._content_generation += 1 - self._pending_source = self.source raise - async def _publish_content(self, source: str, text: str, append: bool, + async def _publish_content(self, acquired: str | PreparedMarkdownPart, text: str, append: bool, is_current: Callable[[], bool]) -> None: + source = acquired.text if isinstance(acquired, PreparedMarkdownPart) else acquired async with self._content_lock: if not is_current(): return @@ -179,11 +177,8 @@ async def _publish_content(self, source: str, text: str, append: bool, # only after those roots have actually been retired. if not super().reconstructible_children(): self._needs_full_markdown_update = True - if self._markdown_part is not None and self._markdown_part.text != source: - self._markdown_part = None fragments = () if self.partitionable_syntax: - acquired = self.acquired_source(source) fragments = (await self.app.render_processes.submit(MarkdownPartsTask(acquired)) if self._paginate else (await self.app.render_processes.submit(MarkdownSyntaxRenderTask(acquired)),)) diff --git a/tests/test_t4_turn_navigation.py b/tests/test_t4_turn_navigation.py index 2d067c4b0..17ec17aa9 100644 --- a/tests/test_t4_turn_navigation.py +++ b/tests/test_t4_turn_navigation.py @@ -9,6 +9,8 @@ from toad.block_navigation import ConversationBlock, UpCursor, DownCursor from toad.widgets.agent_response import AgentResponse from toad.widgets.note import Note +from textual.document._markdown import MarkdownSourceBlock +from textual.widgets._markdown import MarkdownBlock from textual.widgets import Static from runtime_fixture import ToadApp @@ -39,31 +41,134 @@ class ExtraBlock(ConversationBlock, Static): await pilot.pause() view = app.selected_session.conversation await view.contents.remove_children() - note, response, extra = Note("atomic"), AgentResponse("first\n\nsecond"), ExtraBlock("new declaration") + app.stylesheet.add_source("PreparedH2 { display: none; }", read_from=(__file__, "hidden-heading")) + app.refresh_css(animate=False) + source = "# Heading\n\nA paragraph.\n\n## Hidden\n\n```python\nprint('code')\n```\n\n- outer\n - inner\n" + note, response, extra = Note("atomic"), AgentResponse(source), ExtraBlock("new declaration") await view.contents.mount(note, response, extra) await pilot.pause() + await response.restore_body() + measurement = response._body_measurement + paint = measurement.document_paint + document = response.document + demand_width = measurement.width + response.styles.gutter.width + print({"initial_response": { + "measurement": type(measurement).__name__, "width": measurement.width, + "demand_width": demand_width, "rows": measurement.rows, + "dormant": measurement.dormant, "ready": measurement.ready(response), + "attached": response.is_attached, "closing": response._closing, + "source_characters": len(response.source), "loading": response.loading, + "requested_characters": len(response._pending_source), + "initial_source_pending": response._initial_markdown is not None, + "source_generation": response._content_generation, + "fragments": len(response.fragments), "fragment_views": len(response.fragment_views), + "geometry": repr(response.screen._compositor.visible_widgets.get(response)), + "document": document is not None, "paint": paint is not None, + "same_source": None if paint is None or document is None else paint.document.same_source(document), + "paint_current": None if paint is None else paint.is_current(response, demand_width), + }}, flush=True) + part, = response.fragment_views + self.assertEqual(response._pending_source, source) + self.assertEqual(response.source, source) + await part.restore_body() + await pilot.pause() + self.assertFalse(part.query(MarkdownBlock)) + hidden, = (root for root in part.block_document_paint.roots if root.source_text() == "## Hidden\n") + self.assertIsNone(hidden.placement) view.move_cursor(UpCursor()) self.assertIs(view.cursor_block_child, extra) view.move_cursor(UpCursor()) self.assertIs(view.cursor_block, response) + # Entry may precede the focus-dependent document publication. + # The original cursor holds that target through frame admission. + self.assertIs(view.navigation.cursor, part.block_cursor) + await pilot.pause() self.assertIsNotNone(view.cursor_block_child) entered = view.cursor_block_child - visited = [entered] - for _ in range(len(response.displayed_children)): + self.assertIsInstance(entered, MarkdownSourceBlock) + expected = ["- outer\n - inner\n", "```python\nprint('code')\n```\n", + "A paragraph.\n", "# Heading\n", None] + for raw in expected: + await pilot.pause() + await part.restore_body() + cursor = view.navigation.cursor + self.assertEqual(cursor.get_prompt_text(), raw) + if raw is not None: + view.action_copy_to_clipboard() + self.assertEqual(app.clipboard, "print('code')" if raw.startswith("```") else raw) + self.assertFalse(cursor.allow_maximize) + size, render = cursor.export_render() + from rich.console import Console + console = Console(width=size.width, height=size.height, record=True) + with console.capture(): + console.print(render) + self.assertIn(" Date: Thu, 8 Oct 2026 22:16:46 -0400 Subject: [PATCH 09/10] Retain child document resources and keep restoration off input pumps --- src/toad/widgets/history_anchor.py | 8 +- src/toad/widgets/prepared_markdown.py | 37 ++---- src/toad/widgets/streaming_markdown.py | 11 +- src/toad/widgets/transcript_history.py | 8 +- src/toad/widgets/viewport_body.py | 163 ++++++++++++++++++++++--- 5 files changed, 179 insertions(+), 48 deletions(-) diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index e08525b30..a7a07669b 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -152,8 +152,12 @@ def _restore(self, window: "HistoryWindow") -> bool: placed = HistoryAnchor._offset(record, window, require_placement=True) if placed is None: return False - window.scroll_to(y=placed + self.offset, animate=False, immediate=True) - return True + destination = placed + self.offset + window.scroll_to(y=destination, animate=False, immediate=True) + # A provisional native extent may clamp this request. Asking for a + # scroll does not consume source-owned reader intent; only attaining + # the original row in committed geometry completes restoration. + return window.scroll_y == destination class HistoryWindow(VerticalScroll): diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index 608b89b19..9a0ebb9e5 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -346,9 +346,12 @@ def _initialize_document(self, markdown: str | None) -> AwaitComplete: return AwaitComplete.nothing() def retire_body_resources(self) -> None: - """Release reconstructible preparation with the native retirement.""" - self._prepared_markdown = None - self.document = None + """Native controls retire; acquired source belongs to this document. + + Selection, navigation and reflow still need the original resolved + tokens and document identity. Their lifetime ends at source replacement + or disposal, not when reconstructible controls leave the scene. + """ async def materialize_native_body(self): source = self._pending_source @@ -427,13 +430,16 @@ async def _prepare_document(self, document): ) paint = (await app.render_processes.submit(task) if isinstance(app, ToadApp) else await asyncio.to_thread(task.execute)) - cost = (await app.preparation.run_thread(retained_bytes, paint) - if isinstance(app, ToadApp) else await asyncio.to_thread(retained_bytes, paint)) + source = document, self._prepared_markdown + def resource_costs(): + return retained_bytes(paint), retained_bytes(source) + cost, source_cost = (await app.preparation.run_thread(resource_costs) + if isinstance(app, ToadApp) else await asyncio.to_thread(resource_costs)) if (not self.is_attached or self._closing or self._pruning or self.document is not document or not paint.is_current(self, width) or self._body_measurement.width + self.styles.gutter.width != width): return MeasuredSourceBody(self._body_measurement.width, self.measured_rows, 1, - root_empty=self.is_empty) + root_empty=self.is_empty, source_bytes=source_cost) paint = paint.with_presentation( document, root_selection=task.root_selection, selection_style=task.selection_style, selecting=task.selecting, @@ -441,7 +447,7 @@ async def _prepare_document(self, document): self.loading = False self._table_of_contents = paint.table_of_contents self.post_message(Markdown.TableOfContentsUpdated(self, self.table_of_contents).set_sender(self)) - return PreparedDocumentBody(paint, cost) + return PreparedDocumentBody(paint, cost, source_cost) def goto_anchor(self, anchor: str) -> bool: measurement = self._body_measurement @@ -457,23 +463,6 @@ def on_unmount(self) -> None: self._prepared_markdown = None self.document = None - def on_resize(self) -> None: - if self.document is not None and self._body_viewport is None: - self.call_later(self.restore_body) - - def notify_style_update(self) -> None: - super().notify_style_update() - if self.is_attached and self.document is not None and self._body_viewport is None: - self.call_later(self.restore_body) - - def selection_updated(self, selection) -> None: - super().selection_updated(selection) - # Screen owns selection. Its change retires rows with different paint - # inputs, using the same body writer as source/style/width updates. - self._update_body_measurement(self._body_measurement.style_updated(self)) - if self.is_attached and self.document is not None and self._body_viewport is None: - self.call_later(self.restore_body) - async def _parse_tokens( self, parser: MarkdownIt | _ThreadLocalPathParser, markdown: str | PreparedMarkdownPart, *, use_thread: bool, ) -> list[Token] | None: diff --git a/src/toad/widgets/streaming_markdown.py b/src/toad/widgets/streaming_markdown.py index 207eb4b96..6376c7678 100644 --- a/src/toad/widgets/streaming_markdown.py +++ b/src/toad/widgets/streaming_markdown.py @@ -15,7 +15,7 @@ from textual.widget import Widget from textual.app import ComposeResult from textual.widgets import Markdown -from toad.widgets.viewport_body import MaterializingBody, PreparedDocumentBody +from toad.widgets.viewport_body import ChildBody, MaterializingBody, PreparedDocumentBody from toad.widgets.prepared_markdown import PreparedContentRange, PreparedConversationMarkdown from toad.widgets.committed_presentation import SnapshotPresentation @@ -40,13 +40,20 @@ def __init__(self, markdown: str | None = None, *, paginate: bool = True, def compose(self) -> ComposeResult: yield from self._prefix + def live_body_measurement(self, width=0, rows=0, widgets=1): + # Partitioned conversation syntax owns a range of original documents. + # An arbitrary parser still owns its original native scene contract. + if self.partitionable_syntax: + return ChildBody(width, rows, widgets) + return super().live_body_measurement(width, rows, widgets) + def _body(self, fragment: PreparedMarkdownPart) -> PreparedConversationMarkdown: return PreparedConversationMarkdown(fragment.text, markdown_part=fragment, classes="-message-fragment") @property def retained_source_bytes(self) -> int: - return sum(part.retained_bytes for part in self.fragments) + return sum(part.retained_bytes for part in self.fragments) + super().retained_source_bytes def capture_admission(self): return self._content_generation, self.start, self.stop diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 3b6111591..8e72a340b 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -44,7 +44,7 @@ from toad.widgets.user_input import UserInput from toad.widgets.message_divider import AgentActivityDivider, MessageClock from toad.widgets.presentation_window import PresentationBudget -from toad.widgets.viewport_body import MeasuredViewportBody, ViewportBody +from toad.widgets.viewport_body import ChildBody, MeasuredViewportBody, ViewportBody from toad.widgets.committed_presentation import CommittedHistory, TranscriptInputClaim from toad.core.source_events import TranscriptCoverage from toad.widgets.message_filter import ( @@ -149,6 +149,7 @@ class TranscriptFragmentView(MeasuredViewportBody, CategorizedBlock, VerticalGro def __init__(self, fragment: TranscriptFragment, selected=None): super().__init__() + self._body_measurement = self.live_body_measurement() self.fragment = fragment self._message_category = (event_category(fragment.events[0]) if fragment.events else OtherCategory) @@ -156,9 +157,12 @@ def __init__(self, fragment: TranscriptFragment, selected=None): self.set_class(not any(event.routed for event in fragment.events), "-unrouted") self.set_categories(all_categories() if selected is None else selected) + def live_body_measurement(self, width=0, rows=0, widgets=1): + return ChildBody(width, rows, widgets) + @property def retained_source_bytes(self) -> int: - return self.fragment.retained_bytes + return self.fragment.retained_bytes + super().retained_source_bytes def reconstructible_children(self) -> tuple[Widget, ...]: return tuple(self.children) diff --git a/src/toad/widgets/viewport_body.py b/src/toad/widgets/viewport_body.py index 5e68a2cf8..9e412c72a 100644 --- a/src/toad/widgets/viewport_body.py +++ b/src/toad/widgets/viewport_body.py @@ -156,6 +156,11 @@ def render(self, body, crop, render_live) -> list[Strip]: ... @abstractmethod async def restore(self, body) -> None: ... + def request_preparation(self, body): + """Admit detached preparation without waiting on a widget's input FIFO.""" + if self.dormant and self.width > 0 and not self.ready(body): + self.start_materialization(body, exit_on_error=True) + def invalidated(self): return MeasuredBody(self.width, self.rows, self.widgets) @@ -264,6 +269,18 @@ def released(self): def admitted(self): return self + def paint_cost(self, body): + return self.paint_bytes + + def source_cost(self, body): + return 0 + + def release_paint(self, body): + return self.released() + + def retain_paint(self, body): + return self.admitted() + def style_updated(self, body): return self @@ -311,6 +328,10 @@ class MeasuredSourceBody(MeasuredBody): """Committed source membership remains when its paint is released.""" root_empty: bool + source_bytes: int = 0 + + def source_cost(self, body): + return self.source_bytes def source_empty(self, body, native_empty): return self.root_empty @@ -369,6 +390,50 @@ def retire(self, body): return body.retire_native_body(self) +@dataclass(frozen=True) +class ChildBody(LiveBody): + """A native range arranges child resources; it doesn't own their pixels. + + Source admission and headers belong to this container. Each document or + disclosure owns its paint and native controls independently. Capturing + those rows again at the container would erase their source/selection + identity and force rebuilding the same document after every return. + """ + + async def prepare_publication(self, body): + return self + + def paint_cost(self, body): + return sum(child.retained_paint_bytes for child in body.child_bodies()) + + def source_cost(self, body): + return sum(child.retained_source_bytes for child in body.child_bodies()) + + def release_paint(self, body): + for child in body.child_bodies(): + child.release_paint() + return self + + def retain_paint(self, body): + for child in body.child_bodies(): + child.retain_paint() + return self + + def geometry_targets(self, body): + return tuple(dict.fromkeys((body, *(target + for child in body.child_bodies() for target in child.body_geometry_targets())))) + + async def retire(self, body): + # Children retain their own source, measurement and paint. Only an + # actual scene owner may prune its reconstructible native controls. + # Its original retirement checks reacquire exposure and source custody. + retired = False + for child in body.child_bodies(): + if child.is_attached and not child._closing and not child.body_dormant: + retired = await child.retire_body() or retired + return retired + + @dataclass(frozen=True) class CapturingBody(LiveBody): """A live body acquiring complete native rows for its own retirement. @@ -471,6 +536,18 @@ def cost(self, body): def paint_bytes(self): return self.previous.paint_bytes + def paint_cost(self, body): + return self.previous.paint_cost(body) + + def source_cost(self, body): + return self.previous.source_cost(body) + + def release_paint(self, body): + return self._updated(self.previous.release_paint(body)) + + def retain_paint(self, body): + return self._updated(self.previous.retain_paint(body)) + @property def document_paint(self): return self.previous.document_paint @@ -510,6 +587,12 @@ def resized(self, size): async def restore(self, body): await self.before_publication() + def request_preparation(self, body): + # This resource already owns its actual writer. Layout, style and + # selection can invalidate its input, but cannot start a second writer + # or occupy the widget's message pump while the first one finishes. + return + async def materialize(self, body): await self.before_publication() @@ -558,7 +641,7 @@ def publication_finished(self, body, worker, result): # tree. A newer writer still borrows the preceding pixels. if isinstance(result, BodyMeasurement): return result - return LiveBody(self.width, self.rows, self.widgets) + return body.live_body_measurement(self.width, self.rows, self.widgets) return self.previous return self._updated(self.previous.publication_finished(body, worker, result)) @@ -578,7 +661,8 @@ class RenderedBody(MeasuredSourceBody): paint_state: PaintState def invalidated(self): - return MeasuredSourceBody(self.width, self.rows, self.widgets, root_empty=self.root_empty) + return MeasuredSourceBody(self.width, self.rows, self.widgets, + root_empty=self.root_empty, source_bytes=self.source_bytes) @property def dormant(self): @@ -640,6 +724,10 @@ class PreparedDocumentBody(BodyMeasurement): paint: "DocumentPaint" resource_bytes: int + source_bytes: int + + def source_cost(self, body): + return self.source_bytes @property def document_paint(self): @@ -726,7 +814,7 @@ def style_updated(self, body): return self.invalidated() def invalidated(self, width=None): - return PendingDocumentBody(self.paint, self.resource_bytes, + return PendingDocumentBody(self.paint, self.resource_bytes, self.source_bytes, self.width if width is None else width) def resized(self, size): @@ -735,7 +823,7 @@ def resized(self, size): def released(self): return MeasuredSourceBody(self.width, self.rows, self.widgets, - root_empty=self.paint.root_empty) + root_empty=self.paint.root_empty, source_bytes=self.source_bytes) async def restore(self, body): if not self.ready(body): @@ -779,6 +867,20 @@ def __init__(self, *args, **kwargs): self._body_viewport = None super().__init__(*args, **kwargs) + def live_body_measurement(self, width=0, rows=0, widgets=1): + """The declaration supplies the role of its committed native content.""" + return LiveBody(width, rows, widgets) + + def child_bodies(self): + """Original DOM custody, stopping at each independent resource owner.""" + pending = list(reversed(self.children)) + while pending: + child = pending.pop() + if isinstance(child, ViewportBody): + yield child + else: + pending.extend(reversed(child.children)) + @property def body_dormant(self): return self._body_measurement.dormant @@ -844,13 +946,17 @@ def retained_widget_count(self): @property def retained_paint_bytes(self): - return self._body_measurement.paint_bytes + return self._body_measurement.paint_cost(self) + + @property + def retained_source_bytes(self): + return self._body_measurement.source_cost(self) def release_paint(self): - self._update_body_measurement(self._body_measurement.released()) + self._update_body_measurement(self._body_measurement.release_paint(self)) def retain_paint(self): - self._update_body_measurement(self._body_measurement.admitted()) + self._update_body_measurement(self._body_measurement.retain_paint(self)) def invalidate_body(self): self._update_body_measurement(self._body_measurement.invalidated()) @@ -869,8 +975,16 @@ def _update_body_measurement(self, measurement): # well as extent. Publish that change before any native prune awaits; # NodeList removal happens later and cannot invalidate it for us. self.refresh(layout=True) + self.request_body_preparation() + + def request_body_preparation(self): + """The window prepares its cohort; standalone bodies own their writer.""" + if not self.is_attached or self._closing or self._pruning: + return if self._body_viewport is not None: self._body_viewport.request() + else: + self._body_measurement.request_preparation(self) def notify_style_update(self): super().notify_style_update() @@ -878,6 +992,12 @@ def notify_style_update(self): # original subtree rule epoch and inherited native paint values. self._update_body_measurement(self._body_measurement.style_updated(self)) + def selection_updated(self, selection): + super().selection_updated(selection) + # Selection belongs to Screen. The resource derives whether its paint + # changed; the same preparation owner handles style, size and selection. + self._update_body_measurement(self._body_measurement.style_updated(self)) + def _size_updated(self, size, virtual_size, container_size, layout=True): changed = super()._size_updated(size, virtual_size, container_size, layout) self._update_body_measurement(self._body_measurement.resized(size)) @@ -908,11 +1028,7 @@ async def prepare_input(self, event): async def materialize_interactive_body(self): """Acquire actual native controls through their original body owners.""" await self.materialize_body() - children = tuple(child for child in walk_depth_first(self, ViewportBody, with_root=False) - if not any(isinstance(ancestor, ViewportBody) - for ancestor in child.ancestors - if ancestor is not self and self in ancestor.ancestors)) - for child in children: + for child in self.child_bodies(): await child.materialize_interactive_body() async def materialize_body(self): @@ -998,6 +1114,7 @@ def capture_native_paint(self, current, *, sources=None): style_revision = self._subtree_style_revision paint_state = self._resolved_paint_state() root_empty = self.is_empty + source_bytes = current.source_cost(self) captured = tuple(compositor.published_geometry((self,))) if not captured: return unprepared.prepare_publication(self) @@ -1020,7 +1137,7 @@ async def measured(): return RenderedBody( current.width, current.rows, widgets, content=content, resource_bytes=size_bytes, style_revision=style_revision, paint_state=paint_state, - root_empty=root_empty, + root_empty=root_empty, source_bytes=source_bytes, ) return measured() @@ -1040,7 +1157,10 @@ async def release_native_body(self, current): async with self.retirement_custody() as can_commit: if not can_commit or not self.retirement_current(current): return False - self._update_body_measurement(MeasuredBody(current.width, current.rows, current.widgets)) + self._update_body_measurement(MeasuredSourceBody( + current.width, current.rows, current.widgets, + root_empty=self.is_empty, source_bytes=current.source_cost(self), + )) self.retire_body_resources() await self.remove_children(children) return True @@ -1087,19 +1207,26 @@ def on_unmount(self): if self._body_viewport is not None: self._body_viewport.discard(self) self._body_viewport = None - self._update_body_measurement(LiveBody()) + self._update_body_measurement(self.live_body_measurement()) def on_mount(self): from toad.screens.workspace import WorkspaceScreen from toad.widgets.history_anchor import HistoryWindow if isinstance(self.screen, WorkspaceScreen): + outer_body = None for ancestor in self.walk_ancestors(): if isinstance(ancestor, ViewportBody): - return + outer_body = ancestor if isinstance(ancestor, HistoryWindow): self._body_viewport = ancestor.document_viewport - self._body_viewport.register(self) + # Native resource admission stays at the outer boundary. + # Nested bodies use that SAME window's preparation worker, + # rather than queuing worker waits on their own input FIFO. + if outer_body is None: + self._body_viewport.register(self) + self.request_body_preparation() return + self.request_body_preparation() def on_worker_static_extent_ready(self, _event): # The prepared sender commits readiness for visible publication as @@ -1339,7 +1466,7 @@ def source_tail_visible(self) -> bool: def requires_body(self, owner, *, visible=None, protected=None) -> bool: """Native exposure and current interaction own source admission.""" - if owner not in self.owners: + if owner._body_viewport is not self: return False if visible is None: visible = self.window.screen._compositor.visible_widgets From d60731556fef8c911ba4f25539a454ab4be571ef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 9 Oct 2026 00:09:35 -0400 Subject: [PATCH 10/10] Carry original document source and reader demand through scene eviction --- src/toad/block_content.py | 3 + src/toad/block_navigation.py | 155 +++++++--- src/toad/markdown_preparation.py | 254 ++++++++++++++++- src/toad/render_tasks.py | 6 +- src/toad/screens/session_view.py | 5 + src/toad/tool_output.py | 57 +++- src/toad/transcript_preparation.py | 41 ++- src/toad/widgets/agent_response.py | 4 +- src/toad/widgets/conversation.py | 4 + src/toad/widgets/coordination_context.py | 36 ++- src/toad/widgets/history_anchor.py | 146 ++++++++-- src/toad/widgets/incoming_message.py | 23 +- src/toad/widgets/outgoing_message.py | 20 +- src/toad/widgets/prepared_markdown.py | 264 ++++++++---------- src/toad/widgets/streaming_markdown.py | 113 ++++++-- src/toad/widgets/tool_call.py | 8 + src/toad/widgets/transcript_fragments.py | 83 +++++- src/toad/widgets/transcript_history.py | 142 ++++++---- src/toad/widgets/user_input.py | 15 +- src/toad/widgets/viewport_body.py | 15 +- src/toad/work_preparation.py | 12 +- src/toad/workspace_sessions.py | 10 +- tests/block_interaction_pilot.py | 5 +- tests/coordination_context_pilot.py | 2 +- .../native_widget_actions_installed_pilot.py | 5 +- 25 files changed, 1075 insertions(+), 353 deletions(-) diff --git a/src/toad/block_content.py b/src/toad/block_content.py index 8672c5c4f..fc69415cc 100644 --- a/src/toad/block_content.py +++ b/src/toad/block_content.py @@ -10,6 +10,9 @@ class BlockContent: """Non-interactive blocks inherit empty content and expansion behavior.""" + def retain_transcript_source(self, fragment): + """Native content without acquired Markdown has no source to transfer.""" + @cached_property def block_cursor(self): from toad.block_navigation import AtomicBlockCursor diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 298b64b44..63075b241 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -83,6 +83,15 @@ def maximize(self, screen) -> None: def region(self) -> Region | None: return None + def source_regions(self): + return () + + def region_for(self, source) -> Region | None: + return None + + def owns_source(self, source) -> bool: + return False + @property def visible_region(self) -> Region | None: return None @@ -201,29 +210,24 @@ def clear(self): class DocumentBlockCursor(BlockCursor): - """Select original grammar roots; native paint supplies their placement. - - The ordinal is a position in that exact source cohort, not a token ID. - Source identity fences it before a new presentation's roots may supply - geometry. Neither paint nor a second root collection is retained here. - """ + """One source selection across detached paint and actual native controls.""" def __init__(self, block): super().__init__(block) - self._selection: tuple[int, MarkdownSourceBlock] | CursorDirection | None = None + self._selection: MarkdownSourceBlock | CursorDirection | None = None @property def selected(self): if self._selection is None or self.block._closing: return None if isinstance(self._selection, CursorDirection): - paint = self._current_paint() - if paint is None: + sources = self._current_sources() + if sources is None: return None direction = self._selection - if not self._enter_from(paint, direction.entry(len(paint.roots)), direction): + if not self._enter_from(sources, direction.entry(len(sources)), direction): return None - index, original = self._selection + original = self._selection document = self.block.document if (self.block._pending_source != original.document.source or document is not None and not original.document.same_source(document)): @@ -231,18 +235,15 @@ def selected(self): return None # A selected source outlives rows and native controls. Reconstruction # may borrow it, but only admitted paint can replace its placement. - paint = self._current_paint() - if paint is None: + sources = self._current_sources() + if sources is None: return original - if not original.document.same_source(paint.document) or not 0 <= index < len(paint.roots): - self._selection = None - return None - source = paint.roots[index] - if source.source_index != original.source_index or source.placement is None: - self._selection = None - return None - self._selection = index, source - return source + for source in sources: + if self._same_member(source, original): + self._selection = source + return source + self._selection = None + return None @property def active(self): @@ -257,51 +258,80 @@ def _current_paint(self): width = self.block._body_measurement.width + self.block.styles.gutter.width return paint if paint is not None and paint.is_current(self.block, width) else None - def _enter_from(self, paint, index, direction): - while 0 <= index < len(paint.roots): - source = paint.roots[index] - # Native displayed-child admission excludes CSS-hidden/unplaced - # roots. The original producer owns that answer, not paint leaves. - if source.placement is not None: - self._selection = index, source - return True - index += direction.step + @staticmethod + def _same_member(left, right): + return (left.source_index == right.source_index + and left.document.same_source(right.document)) + + def _scene_roots(self): + from textual.widgets._markdown import MarkdownBlock + + measurement = self.block._body_measurement + if (measurement.dormant or not measurement.ready(self.block) + or not self.block.is_attached or self.block._closing): + return None + return tuple(child for child in self.block.displayed_children + if isinstance(child, MarkdownBlock) and child.source_block is not None) + + def _current_sources(self): + paint = self._current_paint() + if paint is not None: + return tuple(source for source in paint.roots if source.placement is not None) + roots = self._scene_roots() + return None if roots is None else tuple(root.source_block for root in roots) + + def _scene_block(self): + source = self.selected + roots = self._scene_roots() + if source is not None and roots is not None: + return next((root for root in roots + if self._same_member(root.source_block, source)), None) + + def _enter_from(self, sources, index, direction): + if 0 <= index < len(sources): + self._selection = sources[index] + return True self._selection = None return False def enter(self, direction): if self.block._closing: return False - paint = self._current_paint() - if paint is None: + sources = self._current_sources() + if sources is None: # Retain the original entry, not a guessed root from stale paint. # Source requests revoke it through this cursor's clear(). self._selection = direction return True - return self._enter_from(paint, direction.entry(len(paint.roots)), direction) + return self._enter_from(sources, direction.entry(len(sources)), direction) def move(self, direction): source = self.selected if source is None: return isinstance(self._selection, CursorDirection) - paint = self._current_paint() - if paint is None: + sources = self._current_sources() + if sources is None: # Copy retains the original selected source while new paint # prepares. It does not admit another root through stale geometry. return True - index = self._selection[0] + direction.step - return self._enter_from(paint, index, direction) + index = next(index for index, member in enumerate(sources) + if self._same_member(member, source)) + direction.step + return self._enter_from(sources, index, direction) def select(self, widget): - # Pointer dispatch acquires actual native controls first. This source - # cursor never fabricates a widget or guesses a root from paint leaves. + roots = self._scene_roots() + if roots is None: + return False + for ancestor in (widget, *widget.ancestors): + if ancestor in roots: + self._selection = ancestor.source_block + return True return False def accepts(self, owner): source = self.selected return (source is not None and isinstance(owner, type(source)) - and source.source_index == owner.source_index - and source.document.same_source(owner.document)) + and self._same_member(source, owner)) def get_clipboard_text(self): source = self.selected @@ -324,11 +354,39 @@ def _placement(self): @property def region(self): - placement = self._placement() - return None if placement is None else placement.region.translate(self.block.region.offset) + source = self.selected + return None if source is None else self.region_for(source) + + def source_regions(self): + """Original displayed source members and their current screen regions.""" + compositor = self.block.screen._compositor + placements = compositor._published_map + paint = self._current_paint() + if paint is not None: + geometry = placements.get(self.block) + if geometry is None: + return () + return tuple((source, source.placement.region.translate(geometry.region.offset)) + for source in paint.roots if source.placement is not None) + roots = self._scene_roots() + return () if roots is None else tuple((root.source_block, geometry.region) + for root in roots + if (geometry := placements.get(root)) is not None) + + def region_for(self, source): + return next((region for member, region in self.source_regions() + if self._same_member(member, source)), None) + + def owns_source(self, source): + document = self.block.get_current_document() + return document is not None and document.same_source(source.document) @property def visible_region(self): + scene = self._scene_block() + if scene is not None: + geometry = scene.screen._compositor.visible_widgets.get(scene) + return None if geometry is None else geometry[0].intersection(geometry[1]) placement = self._placement() geometry = self.block.screen._compositor.visible_widgets.get(self.block) if placement is None or geometry is None: @@ -347,6 +405,15 @@ def scroll_to_center(self, window): def export_render(self): from textual.strip import StripRenderable + scene = self._scene_block() + if scene is not None: + if self.region_for(scene.source_block) is None: + return None + from textual._compositor import Compositor + + compositor = Compositor() + compositor.reflow(scene, scene.outer_size) + return scene.outer_size, compositor.render_full_update() placement = self._placement() if placement is None: return None diff --git a/src/toad/markdown_preparation.py b/src/toad/markdown_preparation.py index 049abf0d9..8d8e192a8 100644 --- a/src/toad/markdown_preparation.py +++ b/src/toad/markdown_preparation.py @@ -1,16 +1,21 @@ """Process-safe conversation parsing and code-fence highlighting data.""" +from contextlib import ExitStack from copy import deepcopy -from collections.abc import Iterable, Iterator +from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, field from functools import cached_property from hashlib import sha256 import pickle +from typing import TYPE_CHECKING from markdown_it.token import Token from textual.content import Content from textual.widgets._markdown import MarkdownBlock, MarkdownFence +if TYPE_CHECKING: + from textual.document._markdown import MarkdownDocument + FenceKey = tuple[str, str, bool, bool] @@ -42,6 +47,27 @@ def capture(cls, text: str) -> "PreparedMarkdownPart": def acquire_tokens(self) -> list[Token]: return deepcopy(list(self.tokens)) + @property + def syntax(self): + return self + + def body(self, declaration, **kwargs): + return declaration(self.text, markdown_part=self, **kwargs) + + async def prepare(self, renderer, ansi, dark): + from toad.render_tasks import MarkdownRenderTask + + await renderer.prepare(MarkdownRenderTask(self, ansi, dark)) + + def resolved_sources(self): + return () + + def independent(self): + return self + + def release_source(self): + return self + def select_blocks(self, text: str, first: int, tokens: slice) -> "PreparedMarkdownPart": """Select complete top-level blocks, retaining their closing tokens.""" if text == self.text and first == 0 and tokens.start == 0 and tokens.stop == len(self.tokens): @@ -106,12 +132,54 @@ class PreparedFence: content: Content -@dataclass(frozen=True) +@dataclass class PreparedMarkdown: tokens: list[Token] fences: dict[FenceKey, PreparedFence] + document: "MarkdownDocument | None" = field(default=None, compare=False, repr=False) + syntax: PreparedMarkdownPart | None = field(default=None, compare=False, repr=False) + retained_bytes: int = field(default=0, compare=False, repr=False) + + def admit_document(self, document: "MarkdownDocument") -> None: + """The independent resolved acquisition owns its native source. + + Pure worker results have no document. acquire_inline_content produces + a separate resource after project links resolve; only that acquisition + receives its document. Presentation does not replace its suppliers. + """ + if self.document is not None and not self.document.same_source(document): + raise ValueError("A new Markdown source requires its own resolved acquisition") + self.document = document + + @property + def text(self): + if self.document is None: + raise RuntimeError("Resolved Markdown source has no acquired document") + return self.document.source + + def body(self, declaration, **kwargs): + if self.document is None: + raise RuntimeError("Resolved Markdown source has no acquired document") + if self.document.declaration is not declaration: + raise ValueError("Acquired source belongs to a different native declaration") + return declaration(self.text, markdown_part=self.syntax, prepared_source=self, **kwargs) + + async def prepare(self, renderer, ansi, dark): + # This independently resolved source has already acquired its content. + # Width/style paint preparation belongs to the restored body resource. + return + + def resolved_sources(self): + return () if self.document is None else (self,) + + def independent(self): + assert self.syntax is not None + return self.syntax - def acquire_inline_content(self, tokens: list[Token]) -> "PreparedMarkdown": + def release_source(self): + return self.independent() + + def acquire_inline_content(self, tokens: list[Token], *, syntax: PreparedMarkdownPart | None = None) -> "PreparedMarkdown": """Bind native content to the actual delivered, link-resolved tokens. The parser's token metadata owns its acquired content. Native block @@ -120,7 +188,7 @@ def acquire_inline_content(self, tokens: list[Token]) -> "PreparedMarkdown": for token in PreparedMarkdownPart._descendants(tokens): if token.type == "inline": token.meta[PreparedMarkdown] = MarkdownBlock._token_to_content(token) - return PreparedMarkdown(tokens, self.fences) + return PreparedMarkdown(tokens, self.fences, syntax=self.syntax if syntax is None else syntax) def inline_content(self, token: Token) -> Content: return token.meta[PreparedMarkdown] @@ -131,7 +199,7 @@ def fence_content(self, code: str, language: str, ansi: bool, dark: bool) -> Con return None if prepared is None else prepared.content -def prepare_tokens(tokens: list[Token], ansi: bool, dark: bool) -> PreparedMarkdown: +def prepare_tokens(tokens: list[Token], ansi: bool, dark: bool, *, syntax: PreparedMarkdownPart | None = None) -> PreparedMarkdown: fences: dict[FenceKey, PreparedFence] = {} for token in tokens: if token.type in {"fence", "code_block"}: @@ -140,4 +208,178 @@ def prepare_tokens(tokens: list[Token], ansi: bool, dark: bool) -> PreparedMarkd if key not in fences: content = MarkdownFence.highlight(code, token.info, ansi=ansi, dark=dark) fences[key] = PreparedFence(content) - return PreparedMarkdown(tokens, fences) + return PreparedMarkdown(tokens, fences, syntax=syntax) + + +class PreparedContentRange: + """Bound native source parts without giving them transcript identity. + + Transcript pages and individual Markdown messages share native admission, + not cursors, coverage, categories or source acquisition. Each owner supplies + its original admission identity and constructs its own part widgets. + Mount commits membership only. BodyMeasurement owns content publication; + the viewport's frame receipt admits paint and the next measured page edge. + Joining child writers here would hold their parent's mutation fence while + those writers need that same window to publish their content. + """ + + BATCH = 4 + + def __init__(self, fragments=(), *, newest: bool = True, batch_size: int = BATCH): + if type(batch_size) is not int or batch_size < 1: + raise ValueError("batch_size must be a positive integer") + self.batch_size = batch_size + self.admission = None + self.admitted = () + self.fragments = fragments + selected = self.initial_slice(fragments, batch_size, newest) + self.start, self.stop = selected.start, selected.stop + + @property + def start(self): + return self.admission[0] + + @start.setter + def start(self, start): + self.admission = start, self.admission[1] if self.admission is not None else start + + @property + def stop(self): + return self.admission[1] + + @stop.setter + def stop(self, stop): + self.admission = self.start, stop + + def configure(self, *, batch_size, newest=True): + self.batch_size = batch_size + if self.admission is None: + selected = self.initial_slice(self.fragments, batch_size, newest) + self.start, self.stop = selected.start, selected.stop + + def resolved_sources(self): + return tuple(source for resource in self.admitted if resource is not None + for source in resource.resolved_sources()) + + def acquired(self, index): + if not self.start <= index < self.stop or not self.admitted: + return None + if len(self.admitted) != self.stop - self.start: + raise RuntimeError("Retained source suppliers do not cover their admission") + return self.admitted[index - self.start] + + def select_admission(self, selected: slice) -> None: + """Move demand within this source, retaining its actual common members. + + The supplier tuple belongs to the preceding admitted interval. Rebase + those original slots before changing its coordinates; an edge or reader + demand cannot reinterpret them as suppliers for different source parts. + """ + admitted = tuple(self.acquired(index) for index in range(selected.start, selected.stop)) if self.admitted else () + retained = {id(resource) for resource in admitted} + for resource in self.admitted: + if resource is not None and id(resource) not in retained: + resource.release_source() + self.admission = selected.start, selected.stop + self.admitted = admitted + + def independent(self): + source = PreparedContentRange(tuple(fragment.independent() for fragment in self.fragments), + batch_size=self.batch_size) + source.admission = self.admission + return source + + def retain_sources(self, view): + fragments = self.fragments[self.start:self.stop] + if len(fragments) != len(view.fragment_views): + raise RuntimeError("Admitted source range lost native members before transfer") + admitted = tuple(view._retain_fragment_source(fragment, body) + for fragment, body in zip(fragments, view.fragment_views)) + retained = {id(resource) for resource in admitted} + for resource in self.admitted: + if resource is not None and id(resource) not in retained: + resource.release_source() + self.admitted = admitted + + @staticmethod + def initial_slice(fragments, batch_size: int, newest: bool) -> slice: + start = max(0, len(fragments) - batch_size) if newest else 0 + return slice(start, min(len(fragments), start + batch_size)) + + def compose(self, view): + view._fragment_views = tuple(view._body(fragment, index) + for index, fragment in enumerate( + self.fragments[self.start:self.stop], self.start)) + yield from view._fragment_views + + def extension_slice(self, older: bool) -> slice: + return (slice(max(0, self.start - self.batch_size), self.start) if older + else slice(self.stop, min(len(self.fragments), self.stop + self.batch_size))) + + def update_slice(self, fragments, follow: bool) -> slice: + if follow: + return self.initial_slice(fragments, self.batch_size, True) + stop = min(self.stop, len(fragments)) + return slice(min(self.start, stop), stop) + + async def extend(self, view, older: bool, current: Callable[[], bool], *, prefix=()) -> bool: + admission = view.capture_admission() + extension = self.extension_slice(older) + selected = slice(extension.start if older else self.start, + self.stop if older else extension.stop) + previous = {self.start + index: child for index, child in enumerate(view.fragment_views)} + return await self.replace_range( + view, self.fragments, selected, previous, + lambda: current() and view.capture_admission() == admission, + prefix=prefix, + ) + + async def replace_range(self, view, fragments, selected, previous, current, *, prefix=()) -> bool: + """Publish ordered native parts inside their owner's source custody.""" + ordered = [] + added = [] + with ExitStack() as acquisition: + acquisition.callback(lambda: view.remove_children(added)) + # Source demand may span a viewport. Native construction/mount + # remains a bounded burst, including after worker preparation. + # Keep the original admission until every batch has joined. + for first in range(selected.start, selected.stop, self.BATCH): + if not current(): + return False + batch = [] + for index in range(first, min(selected.stop, first + self.BATCH)): + if index in previous: + child = previous[index] + else: + child = view._body(fragments[index], index) + added.append(child) + batch.append(child) + ordered.append(child) + if batch: + await view.mount_all(batch) + if not current(): + return False + if not current(): + return False + self.fragments, view._fragment_views = fragments, tuple(ordered) + self.start, self.stop = selected.start, selected.stop + self.retain_sources(view) + desired = (*prefix, *ordered) + if tuple(view.children) != desired: + rank = {child: index for index, child in enumerate(desired)} + view.sort_children(key=lambda child: rank.get(child, len(rank))) + view.remove_children(tuple(child for child in previous.values() if child not in ordered)) + acquisition.pop_all() + return True + + def trim(self, view, count: int, *, older: bool) -> None: + bodies = view.fragment_views + boundary = count if older else len(bodies) - count + retired = bodies[:boundary] if older else bodies[boundary:] + view._fragment_views = bodies[boundary:] if older else bodies[:boundary] + if older: + self.start += count + else: + self.stop -= count + self.retain_sources(view) + view.remove_children(retired) diff --git a/src/toad/render_tasks.py b/src/toad/render_tasks.py index 0a7c574cd..0a7fcbcea 100644 --- a/src/toad/render_tasks.py +++ b/src/toad/render_tasks.py @@ -86,7 +86,9 @@ class MarkdownSyntaxRenderTask(MarkdownSourcePreparation, ReusableRenderTask[Pre source: str | PreparedMarkdownPart def execute(self) -> PreparedMarkdownPart: - return self.acquire_source() + part = self.acquire_source() + part.retained_bytes + return part def accept_result(self, result: object) -> PreparedMarkdownPart: if not isinstance(result, PreparedMarkdownPart): @@ -113,7 +115,7 @@ async def preparation_identity(self, work: RenderPreparation, runtime: Preparati def execute(self) -> PreparedMarkdown: part = self.acquire_source() - return prepare_tokens(part.acquire_tokens(), self.ansi, self.dark) + return prepare_tokens(part.acquire_tokens(), self.ansi, self.dark, syntax=part) def accept_result(self, result: object) -> PreparedMarkdown: if not isinstance(result, PreparedMarkdown): diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 4184031ce..9fc068bd5 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -80,6 +80,11 @@ async def retire_presentation(self) -> None: """Release rich presentation without stopping its operational sources.""" from toad.widgets.history_anchor import HistoryWindow + # Specialized source owners capture their reader before entering this + # shared retirement. Clear its focus before hiding any native resource; + # shared chrome retains its independently owned focus. + if self.has_focus_within: + self.screen.set_focus(None) for window in self.query(HistoryWindow): await window.document_viewport.suspend_source() diff --git a/src/toad/tool_output.py b/src/toad/tool_output.py index 55d034677..94045c302 100644 --- a/src/toad/tool_output.py +++ b/src/toad/tool_output.py @@ -29,6 +29,7 @@ ) from toad.widgets.worker_static import WorkerStatic from toad.rich_preparation import AnsiContentSource, ContentSource, NativeContentSource +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdown if TYPE_CHECKING: from textual.worker import Worker @@ -44,6 +45,12 @@ def admit(self) -> ToolOutputPart: """Borrow immutable source; mutable preparation belongs to its view.""" return self + def retain_source(self): + pass + + def resolved_sources(self): + return () + @abstractmethod def compose(self, view: Widget) -> tuple[Widget, ...]: ... @@ -145,6 +152,14 @@ def render_source(self) -> NativeContentSource: class MarkdownToolOutputPart(SpecificTextToolOutputPart): + def __init__(self, text): + super().__init__(text) + self.prepared_source: PreparedMarkdown | None = None + self._body = None + + def admit(self): + return type(self)(self.text) + @classmethod def accepts(cls, text: str, read_path: str | None) -> bool: return read_path is None and "\x1b" not in text and ( @@ -152,7 +167,18 @@ def accepts(cls, text: str, read_path: str | None) -> bool: ) def compose(self, view: Widget) -> tuple[Widget, ...]: - return (MarkdownContent(self.text),) + body = MarkdownContent(self.text, prepared_source=self.prepared_source) + self._body = ref(body) + return (body,) + + def retain_source(self): + body = None if self._body is None else self._body() + if body is not None: + self.prepared_source = body.prepared_source + self._body = None + + def resolved_sources(self): + return () if self.prepared_source is None else self.prepared_source.resolved_sources() @dataclass(frozen=True) @@ -240,16 +266,32 @@ def preview_dimensions(self) -> tuple[int, int]: return len(self.source), self.source.count("\n") -@dataclass(frozen=True) +@dataclass class SentMessageToolOutputPart(ToolOutputPart): """Render the send owner's original publication through the outgoing view.""" message: SentTranscript + prepared_content: PreparedContentRange | None = field(default=None, compare=False, repr=False) + _body: object = field(default=None, compare=False, repr=False) + + def admit(self): + return replace(self, prepared_content=None, _body=None) def compose(self, view: Widget) -> tuple[Widget, ...]: from toad.widgets.outgoing_message import OutgoingMessage - return (OutgoingMessage(self.message),) + body = OutgoingMessage(self.message, prepared_content=self.prepared_content) + self._body = ref(body) + return (body,) + + def retain_source(self): + body = None if self._body is None else self._body() + if body is not None: + self.prepared_content = body.retain_sources() + self._body = None + + def resolved_sources(self): + return () if self.prepared_content is None else self.prepared_content.resolved_sources() @dataclass(frozen=True) @@ -397,10 +439,15 @@ def capture_parts(tool_call: schema.ToolCall) -> tuple[ToolOutputPart, ...]: def replace(self, tool_call: schema.ToolCall, *, parts: tuple[ToolOutputPart, ...] | None = None) -> None: self.suppress_auto_expansion = tool_call.kind == "read" - parts = self.capture_parts(tool_call) if parts is None else parts + parts = tuple(part.admit() for part in self.capture_parts(tool_call)) if parts is None else parts if parts != self.parts: self.cancel_preparation() - self.parts = tuple(part.admit() for part in parts) + self.parts = parts + + def retain_sources(self): + for part in self.parts: + part.retain_source() + return self.parts @property def has_content(self) -> bool: diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index cbc0a418f..d6ad93480 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -6,11 +6,12 @@ from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import AsyncIterator, Awaitable, Callable -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from agent_comms.transcripts import TranscriptCursor, TranscriptPage from agent_comms.transcript_events import TranscriptEvent +from toad.markdown_preparation import PreparedContentRange from toad.widgets.transcript_fragments import TranscriptFragment from toad.widgets.transcript_fragments import TranscriptRenderTask from toad.widgets.message_filter import MessageCategory, keep_events @@ -29,11 +30,22 @@ @dataclass -class PreparedTranscriptPage: +class PreparedTranscriptPage(PreparedContentRange): page: TranscriptPage fragments: tuple[TranscriptFragment, ...] retained_bytes: int admission: tuple[int, int] | None = None + batch_size: int = field(default=PreparedContentRange.BATCH, kw_only=True) + admitted: tuple[TranscriptFragment, ...] = field(default=(), kw_only=True, compare=False, repr=False) + + def admit(self) -> PreparedTranscriptPage: + """An independent view owns admission and resolved source acquisitions. + + The worker's immutable source/syntax remains reusable. Its stored page + never acquires a frontend document or another projection's lifetime. + """ + return replace(self, fragments=tuple(fragment.independent() for fragment in self.fragments), + admission=None, admitted=()) @dataclass(frozen=True) @@ -103,6 +115,26 @@ def retained_source_bytes(self) -> int: self.projection_source.retained_source_bytes if self.projection_source is not None else 0 ) + def measure_admission(self, pages: tuple[PreparedTranscriptPage, ...], + projection: ProjectedTranscriptSource | None = None, + *, seen: set[int] | None = None) -> None: + """Partition retained source custody in the existing preparation worker. + + A tool's mutable acquisition need not be a field of its immutable + dataclass input. Include each owner's declared suppliers, rather than + assuming a graph walk of transport fields reaches every document. + Canonical and projected pages share immutable inputs; their independent + acquisitions remain distinct while this one traversal counts sharing. + """ + if seen is None: + seen = set() + for page in pages: + page.retained_bytes = retained_bytes( + (page.page, page.fragments, page.resolved_sources()), seen=seen, + ) + if projection is not None: + projection.measure_admission(projection.admitted, projection.projection_source, seen=seen) + def park(self, pages: tuple[PreparedTranscriptPage, ...], projection: ProjectedTranscriptSource | None = None) -> None: if self.closed: @@ -151,7 +183,8 @@ async def project(self, page: PreparedTranscriptPage, runtime: PreparationRuntim matches = await runtime.submit(TranscriptFilterWork( page.fragments, self.selected, len(page.fragments), len(page.fragments), )) - return replace(page, fragments=matches.fragments, admission=None) + return replace(page, fragments=tuple(fragment.independent() for fragment in matches.fragments), + admission=None, admitted=()) @dataclass(frozen=True) @@ -223,7 +256,7 @@ async def get(self, request: PageRequest) -> PreparedTranscriptPage: raise ValueError("This transcript has no saved-source reader") result = await self.runtime.submit(TranscriptPageWork(self.scope, self.loader, self.through, request)) self._blocked.pop(request, None) - return result + return result.admit() async def prefetch( self, before: TranscriptCursor | None, after: TranscriptCursor | None, diff --git a/src/toad/widgets/agent_response.py b/src/toad/widgets/agent_response.py index 6e92fbc8f..317731cdd 100644 --- a/src/toad/widgets/agent_response.py +++ b/src/toad/widgets/agent_response.py @@ -8,7 +8,7 @@ from toad.widgets.message_divider import MessageClock, LiveMessageClock, MessageDivider from toad.widgets.route_header import RouteHeader from toad import response_delivery -from toad.markdown_preparation import PreparedMarkdownPart +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdownPart from toad.widgets.message_filter import ( CategorizedBlock, MessageCategory, @@ -34,11 +34,13 @@ def message_category(self) -> type[MessageCategory]: def __init__(self, markdown: str | None = None, *, delivery: response_delivery.ResponseDelivery = response_delivery.UnroutedResponse(), category: type[MessageCategory] | None = None, markdown_part: PreparedMarkdownPart | None = None, + prepared_content: PreparedContentRange | None = None, paginate: bool = True, show_divider: bool = True, clock: MessageClock = LiveMessageClock()) -> None: self._message_category = category or delivery.category super().__init__( markdown, markdown_part=markdown_part, + prepared_content=prepared_content, paginate=paginate, **self.dispatch_sync(delivery, clock, show_divider), ) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index d0aae5112..5e446f5a6 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -343,6 +343,10 @@ def rebind_screen(self, previous, destination) -> None: viewport.membership.bind(destination.viewport_presentation) class Window(ConversationWindowSettings, HistoryWindow): + @property + def pending_reader_position(self): + return self.query_ancestor(Conversation).transcript.reader_position + HELP = """\ ## Conversation diff --git a/src/toad/widgets/coordination_context.py b/src/toad/widgets/coordination_context.py index a46e86352..a075a6dcd 100644 --- a/src/toad/widgets/coordination_context.py +++ b/src/toad/widgets/coordination_context.py @@ -4,12 +4,16 @@ from toad.block_navigation import ConversationBlock import asyncio +from typing import TYPE_CHECKING from textual.widgets import Collapsible from toad.widgets.agent_response import AgentResponse from toad.coordination_context_format import format_coordination_context, literal_context +if TYPE_CHECKING: + from toad.widgets.transcript_fragments import ContextTranscriptFragment + class ContextDisclosure(ConversationBlock, Collapsible): @@ -36,22 +40,28 @@ def collapse_block(self) -> None: class OriginalCoordinationContext(ContextDisclosure): - def __init__(self, content: str) -> None: + def __init__(self, content: str, *, source: "ContextTranscriptFragment") -> None: super().__init__(content, title="Original payload") self._body: AgentResponse | None = None + self.source = source async def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: if event.collapsible is self and self._body is None: self._body = AgentResponse(literal_context(self.content), show_divider=False, - category=OtherCategory) + category=OtherCategory, prepared_content=self.source.original_content) await self.query_one(Collapsible.Contents).mount(self._body) + def retain_transcript_source(self, fragment): + if self._body is not None: + self.source.original_content = self._body.retain_sources() + class CoordinationContext(ContextDisclosure): - def __init__(self, content: str) -> None: + def __init__(self, content: str, *, source: "ContextTranscriptFragment") -> None: super().__init__(content, title="Agent coordination context") self._body: AgentResponse | None = None - self._formatted: str | None = None + self.source = source + self._original: OriginalCoordinationContext | None = None self._preparing = False async def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: @@ -61,14 +71,22 @@ async def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: try: # Neither JSON formatting nor Markdown preparation belongs in the # hidden transcript's paint path. Keep one lazy result per disclosure. - if self._formatted is None: - self._formatted = await asyncio.to_thread(format_coordination_context, self.content) + if self.source.formatted is None: + self.source.formatted = await asyncio.to_thread(format_coordination_context, self.content) if not self.is_attached or self.collapsed: return - self._body = AgentResponse(self._formatted, show_divider=False, category=OtherCategory) + self._body = AgentResponse(self.source.formatted, show_divider=False, category=OtherCategory, + prepared_content=self.source.prepared_content) contents = self.query_one(Collapsible.Contents) await contents.mount(self._body) - if self._formatted != self.content: - await contents.mount(OriginalCoordinationContext(self.content)) + if self.source.formatted != self.content: + self._original = OriginalCoordinationContext(self.content, source=self.source) + await contents.mount(self._original) finally: self._preparing = False + + def retain_transcript_source(self, fragment): + if self._body is not None: + self.source.prepared_content = self._body.retain_sources() + if self._original is not None: + self._original.retain_transcript_source(fragment) diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index a7a07669b..a1b8a7d57 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import cached_property from contextlib import AsyncExitStack, asynccontextmanager, contextmanager import asyncio @@ -18,6 +18,7 @@ from toad.widgets.presentation_window import protected_presentations if TYPE_CHECKING: + from textual.document._markdown import MarkdownSourceBlock from toad.widgets.tool_call import ToolCall from toad.widgets.transcript_history import TranscriptPageAdmission from toad.transcript_source_preparation import TranscriptSourcePreparation @@ -90,9 +91,25 @@ def capture(cls, window: "HistoryWindow") -> "ReaderPosition": for admission in history.capture_reader_admissions() ) visible = window.screen._compositor.visible_widgets - record = min(window.visible_history_items( + records = tuple(window.visible_history_items( fragment for history in histories for fragment in history.fragment_views - ), key=lambda fragment: visible[fragment][0].y, default=None) + )) + viewport = window.content_region.intersection(window.screen.region) + sources = ( + (record, source, region) + for record in records + for source, region in FragmentReaderPosition.source_regions(record) + if region.overlaps(viewport) + ) + first = min(sources, key=lambda item: max(item[2].y, viewport.y), default=None) + if first is not None: + record, source, region = first + placement = FragmentReaderPosition.source_offset(record, window, region) + if placement is not None: + return DocumentReaderPosition( + record.fragment, window.scroll_y - placement, admissions, source=source, + ) + record = min(records, key=lambda fragment: visible[fragment][0].y, default=None) if record is not None: return FragmentReaderPosition( record.fragment, window.scroll_y - HistoryAnchor._offset(record, window), @@ -104,6 +121,20 @@ def prepare_history(self, history: "TranscriptSourcePreparation") -> None: """Restore admitted source ranges; tail readers have no older admission.""" history.restore_reader_admissions(self.admissions) + def required_bodies(self, window: "HistoryWindow") -> tuple[Widget, ...]: + """Source readers supply their preparation and native placement demand.""" + return () + + @staticmethod + def _restore_row(window: "HistoryWindow", destination: float | None) -> bool: + window.release_anchor() + if destination is None: + return False + window.scroll_to(y=destination, animate=False, immediate=True) + # A provisional native extent may clamp the request. Only attaining + # the original reading row completes this owner's restoration. + return window.scroll_y == destination + @dataclass(frozen=True) class TailReaderPosition(ReaderPosition): @@ -118,9 +149,7 @@ class OffsetReaderPosition(ReaderPosition): admissions: tuple["TranscriptPageAdmission", ...] def _restore(self, window: "HistoryWindow") -> bool: - window.release_anchor() - window.scroll_to(y=self.y, animate=False, immediate=True) - return True + return self._restore_row(window, self.y) @dataclass(frozen=True) @@ -144,20 +173,89 @@ def current(self, window: "HistoryWindow") -> bool: record = self.record(window) return record is not None and record.display - def _restore(self, window: "HistoryWindow") -> bool: - window.release_anchor() + def required_bodies(self, window: "HistoryWindow") -> tuple[Widget, ...]: + record = self.record(window) + return () if record is None else (record,) + + def placement(self, window: "HistoryWindow") -> int | None: record = self.record(window) if record is None or not record.body_ready: - return False + return None + return HistoryAnchor._offset(record, window, require_placement=True) + + @staticmethod + def source_bodies(record): + """Borrow original body boundaries and each source cursor's geometry. + + Containers own child resources; no reader walks prepared native blocks + or builds an alternate source/placement catalog. + """ + from toad.block_content import BlockContent + + pending = [record] + while pending: + body = pending.pop() + if isinstance(body, BlockContent): + yield body + pending.extend(reversed(tuple(body.child_bodies()))) + + @classmethod + def source_regions(cls, record): + for body in cls.source_bodies(record): + yield from body.block_cursor.source_regions() + + @staticmethod + def source_offset(record, window, region) -> int | None: + geometry = window.screen._compositor._published_map.get(record) + if geometry is None: + return None placed = HistoryAnchor._offset(record, window, require_placement=True) - if placed is None: + return None if placed is None else placed + region.y - geometry.region.y + + def _restore(self, window: "HistoryWindow") -> bool: + placed = self.placement(window) + return self._restore_row(window, None if placed is None else placed + self.offset) + + +@dataclass(frozen=True) +class DocumentReaderPosition(FragmentReaderPosition): + """The actual Markdown member owns the reading row, not its headers. + + Row offset is relative to that source block's committed placement. This + preserves the reader across scene eviction and changing preceding rows; + it does not claim a character position through a width-dependent rewrap. + """ + + source: MarkdownSourceBlock = field(kw_only=True) + + def required_bodies(self, window: "HistoryWindow") -> tuple[Widget, ...]: + record = self.record(window) + if record is None: + return () + return (record, *(body for body in self.source_bodies(record) + if body.block_cursor.owns_source(self.source))) + + def current(self, window: "HistoryWindow") -> bool: + if not super().current(window): return False - destination = placed + self.offset - window.scroll_to(y=destination, animate=False, immediate=True) - # A provisional native extent may clamp this request. Asking for a - # scroll does not consume source-owned reader intent; only attaining - # the original row in committed geometry completes restoration. - return window.scroll_y == destination + return (any( + acquired.document is not None + and self.source.document.same_source(acquired.document) + for acquired in self.fragment.resolved_sources() + ) or any( + body.block_cursor.owns_source(self.source) + for body in self.source_bodies(self.record(window)) + )) + + def placement(self, window: "HistoryWindow") -> int | None: + record = self.record(window) + if record is None: + return None + for source, region in self.source_regions(record): + if (source.source_index == self.source.source_index + and source.document.same_source(self.source.document)): + return self.source_offset(record, window, region) + return None class HistoryWindow(VerticalScroll): @@ -215,9 +313,20 @@ def prepare_history_layout(self) -> HistoryAnchor | None: return self.history_anchor return None + @property + def pending_reader_position(self) -> ReaderPosition | None: + """A source-owning window lends its actual pending restoration.""" + return None + + @property + def reader_bodies(self) -> tuple[Widget, ...]: + position = self.pending_reader_position + return () if position is None else position.required_bodies(self) + def history_geometry_targets(self) -> tuple[Widget, ...]: anchor = self.history_anchor - return anchor.geometry_targets if anchor is not None else () + return tuple(dict.fromkeys((*self.reader_bodies, + *(anchor.geometry_targets if anchor is not None else ())))) def restore_history_layout(self, position: HistoryAnchor) -> bool: previous = self.scroll_y @@ -509,8 +618,7 @@ def _offset(widget: Widget, window: Widget, *, require_placement: bool = False) # reflow Textual may still flag its former scroll map as invalidated; # asking virtual_region then needlessly computes the entire tree again # merely to recover the anchor's already-measured coordinates. - geometry = (compositor._visible_map if compositor._visible_map is not None - else compositor._full_map) + geometry = compositor._published_map while node is not window: placed = geometry.get(node) if placed is None and require_placement: diff --git a/src/toad/widgets/incoming_message.py b/src/toad/widgets/incoming_message.py index e5d426c7a..41cc7aabf 100644 --- a/src/toad/widgets/incoming_message.py +++ b/src/toad/widgets/incoming_message.py @@ -12,7 +12,7 @@ from toad.widgets.committed_presentation import CommitParticipant, SequenceClaim from toad.widgets.wire_message_handling import WireMessageHandling from toad.widgets.message_notifications import MessageNotifications -from toad.markdown_preparation import PreparedMarkdownPart +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdownPart @@ -28,11 +28,14 @@ def commit_claim(self) -> SequenceClaim: return SequenceClaim(self.sequence) def __init__(self, event: IncomingTranscript, *, show_header: bool = True, - markdown_part: PreparedMarkdownPart | None = None) -> None: + markdown_part: PreparedMarkdownPart | None = None, + prepared_content: PreparedContentRange | None = None, paginate: bool = True) -> None: super().__init__() self.event = event self.show_header = show_header self.markdown_part = markdown_part + self._content_transfer = prepared_content + self.paginate = paginate @property def message_reference(self): @@ -50,9 +53,23 @@ def compose(self) -> ComposeResult: clock=MessageClock.recorded(self.event.timestamp)) yield RouteHeader(self.event.route, incoming=True) yield AgentResponse(self.event.text, show_divider=False, - markdown_part=self.markdown_part).add_class("routed-body") + markdown_part=self.markdown_part, prepared_content=self._content_transfer, + paginate=self.paginate).add_class("routed-body") if self.handling_references: yield MessageNotifications() + @property + def prepared_content(self): + from toad.widgets.agent_response import AgentResponse + + body = self.query_one_optional(AgentResponse) + return self._content_transfer if body is None else body.prepared_content + + def retain_transcript_source(self, fragment): + from toad.widgets.agent_response import AgentResponse + + body = self.query_one_optional(AgentResponse) + fragment.prepared_content = self._content_transfer if body is None else body.retain_sources() + def get_clipboard_text(self) -> str: return f"{self.event.route.incoming_label}\n{self.event.text}" diff --git a/src/toad/widgets/outgoing_message.py b/src/toad/widgets/outgoing_message.py index a8d07af68..c2be020ff 100644 --- a/src/toad/widgets/outgoing_message.py +++ b/src/toad/widgets/outgoing_message.py @@ -11,18 +11,21 @@ from toad.widgets.message_filter import CategorizedBlock, OutboundCategory from toad.widgets.message_notifications import MessageNotifications from toad.widgets.wire_message_handling import WireMessageHandling -from toad.markdown_preparation import PreparedMarkdownPart +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdownPart class OutgoingMessage(WireMessageHandling, ConversationBlock, CategorizedBlock, VerticalGroup): DEFAULT_CLASSES = "block" def __init__(self, event: SentTranscript, *, show_header: bool = True, - markdown_part: PreparedMarkdownPart | None = None): + markdown_part: PreparedMarkdownPart | None = None, + prepared_content: PreparedContentRange | None = None, paginate: bool = True): super().__init__() self.event = event self.show_header = show_header self.markdown_part = markdown_part + self._content_transfer = prepared_content + self.paginate = paginate @property def message_reference(self): @@ -35,8 +38,21 @@ def message_category(self): def compose(self) -> ComposeResult: yield AgentResponse(self.event.text, markdown_part=self.markdown_part, + prepared_content=self._content_transfer, paginate=self.paginate, delivery=ResponseDelivery.from_route(self.event.routing.reply), show_divider=self.show_header, clock=MessageClock.recorded(self.event.timestamp)) if self.handling_references: yield MessageNotifications() + + @property + def prepared_content(self): + body = self.query_one_optional(AgentResponse) + return self._content_transfer if body is None else body.prepared_content + + def retain_transcript_source(self, fragment): + fragment.prepared_content = self.retain_sources() + + def retain_sources(self): + body = self.query_one_optional(AgentResponse) + return self._content_transfer if body is None else body.retain_sources() diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index 9a0ebb9e5..2cc75ad2c 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -1,7 +1,7 @@ """Use the app-owned CPU pool for heavy conversation Markdown preparation.""" import asyncio -from contextlib import ExitStack, asynccontextmanager +from contextlib import asynccontextmanager from collections.abc import Callable from functools import cached_property, partial from typing import cast @@ -12,7 +12,7 @@ from textual.content import Content from textual.app import ComposeResult -from textual.worker import WorkerCancelled +from textual.worker import WorkerCancelled, get_current_worker from textual.await_complete import AwaitComplete from textual.widget import Widget from textual.visual import Visual @@ -21,7 +21,7 @@ from toad.app import ToadApp from toad.conversation_markdown import ConversationCodeFence, ConversationMarkdown, _ThreadLocalPathParser -from toad.markdown_preparation import PreparedMarkdown, PreparedMarkdownPart +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdown, PreparedMarkdownPart from toad.block_content import MarkdownBlockContent from toad.block_navigation import ChildBlockCursor, DocumentBlockCursor from toad.layout import trim_trailing_margin @@ -31,125 +31,6 @@ from toad.widgets.worker_static import WorkerStatic -class PreparedContentRange: - """Bound native source parts without giving them transcript identity. - - Transcript pages and individual Markdown messages share native admission, - not cursors, coverage, categories or source acquisition. Each owner supplies - its original admission identity and constructs its own part widgets. - Mount commits membership only. BodyMeasurement owns content publication; - the viewport's frame receipt admits paint and the next measured page edge. - Joining child writers here would hold their parent's mutation fence while - those writers need that same window to publish their content. - """ - - BATCH = 4 - - def __init__(self, *args, fragments=(), newest: bool = True, - batch_size: int = BATCH, **kwargs): - if type(batch_size) is not int or batch_size < 1: - raise ValueError("batch_size must be a positive integer") - self.batch_size = batch_size - self.fragments = fragments - selected = self.initial_slice(fragments, batch_size, newest) - self.start, self.stop = selected.start, selected.stop - self._fragment_views = () - super().__init__(*args, **kwargs) - - @staticmethod - def initial_slice(fragments, batch_size: int, newest: bool) -> slice: - start = max(0, len(fragments) - batch_size) if newest else 0 - return slice(start, min(len(fragments), start + batch_size)) - - def _body(self, fragment): - raise NotImplementedError - - def compose(self): - self._fragment_views = tuple(self._body(fragment) - for fragment in self.fragments[self.start:self.stop]) - yield from self._fragment_views - - @property - def fragment_views(self): - return self._fragment_views - - def on_unmount(self) -> None: - self._fragment_views = () - - def capture_admission(self): - raise NotImplementedError - - def extension_slice(self, older: bool) -> slice: - return (slice(max(0, self.start - self.batch_size), self.start) if older - else slice(self.stop, min(len(self.fragments), self.stop + self.batch_size))) - - def update_slice(self, fragments, follow: bool) -> slice: - if follow: - return self.initial_slice(fragments, self.batch_size, True) - stop = min(self.stop, len(fragments)) - return slice(min(self.start, stop), stop) - - async def extend(self, older: bool, current: Callable[[], bool], *, prefix=()) -> bool: - admission = self.capture_admission() - extension = self.extension_slice(older) - selected = slice(extension.start if older else self.start, - self.stop if older else extension.stop) - previous = {self.start + index: child for index, child in enumerate(self.fragment_views)} - return await self.replace_range( - self.fragments, selected, previous, - lambda: current() and self.capture_admission() == admission, - prefix=prefix, - ) - - async def replace_range(self, fragments, selected, previous, current, *, prefix=()) -> bool: - """Publish ordered native parts inside their owner's source custody.""" - ordered = [] - added = [] - with ExitStack() as acquisition: - acquisition.callback(lambda: self.remove_children(added)) - # Source demand may span a viewport. Native construction/mount - # remains a bounded burst, including after worker preparation. - # Keep the original admission until every batch has joined. - for first in range(selected.start, selected.stop, self.BATCH): - if not current(): - return False - batch = [] - for index in range(first, min(selected.stop, first + self.BATCH)): - if index in previous: - child = previous[index] - else: - child = self._body(fragments[index]) - added.append(child) - batch.append(child) - ordered.append(child) - if batch: - await self.mount_all(batch) - if not current(): - return False - if not current(): - return False - self.fragments, self._fragment_views = fragments, tuple(ordered) - self.start, self.stop = selected.start, selected.stop - desired = (*prefix, *ordered) - if tuple(self.children) != desired: - rank = {child: index for index, child in enumerate(desired)} - self.sort_children(key=lambda child: rank.get(child, len(rank))) - self.remove_children(tuple(child for child in previous.values() if child not in ordered)) - acquisition.pop_all() - return True - - def trim(self, count: int, *, older: bool) -> None: - bodies = self.fragment_views - boundary = count if older else len(bodies) - count - retired = bodies[:boundary] if older else bodies[boundary:] - self._fragment_views = bodies[boundary:] if older else bodies[:boundary] - if older: - self.start += count - else: - self.stop -= count - self.remove_children(retired) - - class PreparedMarkdownContent: """Native blocks keep token/link custody; WorkerStatic owns their wrapping.""" @@ -279,7 +160,7 @@ def block_document_paint(self): presentation prepares, but never a replacement source or detached view. """ paint = self._body_measurement.document_paint - document = self.document + document = self.get_current_document() if (not self.is_attached or self._closing or paint is None or document is None or document.source != self.source or not paint.document.same_source(document)): @@ -289,6 +170,30 @@ def block_document_paint(self): def native_body_ready(self) -> bool: return super().native_body_ready() and not self.loading + @property + def document(self): + source = self._prepared_markdown + return None if source is None else source.document + + @document.setter + def document(self, document): + if document is None: + self._prepared_markdown = None + else: + assert self._prepared_markdown is not None + self._prepared_markdown.admit_document(document) + + @property + def prepared_source(self): + """Actual acquired source, independent of its current paint receipt.""" + source = self._prepared_markdown + return (source if self.owns_requested_source() and source is not None and source.document is not None + and source.document.source == self._pending_source else None) + + def owns_requested_source(self): + owner = self._content_owner + return owner is None or getattr(self.parent, "prepared_content", None) is owner + def _measured_virtual_size_requires_layout(self) -> bool: # Markdown extent comes from its arranged blocks, not a separately # authored virtual document. Committing that result must not feed it @@ -301,13 +206,20 @@ def __init__( id: str | None = None, classes: str | None = None, parser_factory: Callable[[], MarkdownIt] | None = None, open_links: bool = False, markdown_part: PreparedMarkdownPart | None = None, + prepared_source: PreparedMarkdown | None = None, + content_owner: PreparedContentRange | None = None, ) -> None: - self._prepared_markdown: PreparedMarkdown | None = None - self.document = None + self._prepared_markdown = prepared_source + self._content_owner = content_owner self._markdown_part = markdown_part # Requested text survives pending publication, cancellation and scene # retirement. Native .source describes the text its scene consumed. self._pending_source = markdown or "" + if prepared_source is not None: + document = prepared_source.document + if (document is None or document.declaration is not type(self) + or document.source != self._pending_source): + raise RuntimeError("Retained Markdown source does not supply this declaration and request") factory = self._make_parser if parser_factory is None else parser_factory super().__init__(markdown, name=name, id=id, classes=classes, parser_factory=factory, open_links=open_links) @@ -328,7 +240,8 @@ def acquired_source(self, markdown: str | PreparedMarkdownPart) -> str | Prepare part = self._markdown_part return part if part is not None and part.text == markdown else markdown - def update_part(self, part: PreparedMarkdownPart) -> AwaitComplete: + def update_part(self, part: PreparedMarkdownPart, *, content_owner: PreparedContentRange | None = None) -> AwaitComplete: + self._content_owner = content_owner self._markdown_part = part return self.update(part.text) @@ -341,8 +254,13 @@ def _initialize_document(self, markdown: str | None) -> AwaitComplete: # content. Observe that original receipt after completion; waiting on # this pump would hold wheel delivery behind background preparation. # Native source consumption and TOC completion remain unchanged. - initial = None if markdown is None and not self._pending_source else self._pending_source - super()._initialize_document(initial).call_when_ready(self) + if self._prepared_markdown is not None: + if self.prepared_source is None: + raise RuntimeError("Retained Markdown source lost its current native admission") + self.publish_body(self.materialize_native_body).call_when_ready(self) + else: + initial = None if markdown is None and not self._pending_source else self._pending_source + super()._initialize_document(initial).call_when_ready(self) return AwaitComplete.nothing() def retire_body_resources(self) -> None: @@ -354,17 +272,54 @@ def retire_body_resources(self) -> None: """ async def materialize_native_body(self): + if not self.owns_requested_source(): + return False source = self._pending_source - if self.document is not None and self.document.source == source: + self._markdown = source + document = self.get_current_document() + if document is not None: # Width/style demand changes presentation, not parser or resolved # link acquisition. Borrow this original delivered source resource. - return await self._prepare_document(self.document.with_presentation(self)) + return await self._prepare_document(document.with_presentation(self)) return await self._update_body_source(self.acquired_source(source)) async def materialize_interactive_body(self): # The original pointer owner acquires actual controls before choosing # a receiver. It never dispatches a synthetic click against paint. - await self.publish_body(partial(ConversationMarkdown.update, self, self._pending_source)) + if not self.partitionable_syntax: + # A custom grammar still owns its original scene acquisition. + await self.publish_body(partial(ConversationMarkdown.update, self, self._pending_source)) + return + + async def acquire_controls(): + # The preceding writer may have delivered a newer source while + # this publication joined it. Borrow that actual resource here, + # rather than capturing its predecessor before the join. + paint = self._body_measurement.document_paint + current = self.get_current_document() + if current is None: + raise RuntimeError("Prepared Markdown interaction has no acquired source document") + if paint is None: + raise RuntimeError("Prepared Markdown interaction has no completed document paint") + if not current.same_source(paint.document): + raise RuntimeError("Prepared Markdown interaction paint belongs to a different source") + await self.materialize_document(paint) + # The native producer checks source custody across its awaits. + # A revoked acquisition must not be reported as live controls. + return None if self.is_current_document(paint.document) else False + + if (not self.is_attached or self._closing or not self.body_dormant + or not self.owns_requested_source()): + return + await self.restore_body() + if (not self.is_attached or self._closing or not self.body_dormant + or not self.owns_requested_source()): + return + await self.publish_body(acquire_controls) + + def get_current_document(self): + source = self.prepared_source + return None if source is None else source.document @classmethod def document_root(cls, document, children): @@ -395,6 +350,9 @@ def _request_source(self, markdown: str, *, append: bool) -> str: if not append: self.block_cursor.clear() self._pending_source = self._pending_source + markdown if append else markdown + # Even an equal-text replacement is a new independent acquisition. + # Retained rows do not grant the preceding suppliers source custody. + self._prepared_markdown = None if self._markdown_part is not None and self._markdown_part.text != self._pending_source: self._markdown_part = None return self._pending_source @@ -417,6 +375,8 @@ async def _update_body_source(self, source: str | PreparedMarkdownPart): async def _prepare_document(self, document): self.document = document + source = self._prepared_markdown + assert source is not None width = self._body_measurement.width + self.styles.gutter.width if width <= 0: return MeasuredSourceBody(self._body_measurement.width, self.measured_rows, 1, @@ -430,24 +390,26 @@ async def _prepare_document(self, document): ) paint = (await app.render_processes.submit(task) if isinstance(app, ToadApp) else await asyncio.to_thread(task.execute)) - source = document, self._prepared_markdown + paint = paint.with_presentation( + document, root_selection=task.root_selection, + selection_style=task.selection_style, selecting=task.selecting, + ) def resource_costs(): - return retained_bytes(paint), retained_bytes(source) + seen = set() + source_cost = retained_bytes(source, seen=seen) + return retained_bytes(paint, seen=seen), source_cost cost, source_cost = (await app.preparation.run_thread(resource_costs) if isinstance(app, ToadApp) else await asyncio.to_thread(resource_costs)) + source.retained_bytes = source_cost if (not self.is_attached or self._closing or self._pruning or self.document is not document or not paint.is_current(self, width) or self._body_measurement.width + self.styles.gutter.width != width): return MeasuredSourceBody(self._body_measurement.width, self.measured_rows, 1, root_empty=self.is_empty, source_bytes=source_cost) - paint = paint.with_presentation( - document, root_selection=task.root_selection, - selection_style=task.selection_style, selecting=task.selecting, - ) self.loading = False self._table_of_contents = paint.table_of_contents self.post_message(Markdown.TableOfContentsUpdated(self, self.table_of_contents).set_sender(self)) - return PreparedDocumentBody(paint, cost, source_cost) + return PreparedDocumentBody(paint, cost, source) def goto_anchor(self, anchor: str) -> bool: measurement = self._body_measurement @@ -469,6 +431,16 @@ async def _parse_tokens( # Custom parser factories retain their native semantics. This process # function implements the declaration-owned conversation/path parser. parent = self.parent + writer = get_current_worker() + + def current(): + # A preceding writer can remain in the publication chain while a + # later request joins it. Only the current writer may install source. + return (not self._closing and not self._pruning + and self.parent is parent + and self.owns_requested_source() + and getattr(self._body_measurement, "worker", None) is writer) + if type(parser) is not _ThreadLocalPathParser: markdown = markdown.text if isinstance(markdown, PreparedMarkdownPart) else markdown tokens = await super()._parse_tokens(parser, markdown, use_thread=use_thread) @@ -476,7 +448,7 @@ async def _parse_tokens( prepared = await asyncio.to_thread( PreparedMarkdown(tokens, {}).acquire_inline_content, tokens, ) - if self._closing or self._pruning or self.parent is not parent: + if not current(): return None self._prepared_markdown = prepared return tokens @@ -485,10 +457,11 @@ async def _parse_tokens( def acquire_local(): tokens = (parser.resolve_tokens(source.acquire_tokens()) if isinstance(source, PreparedMarkdownPart) else parser.parse(source)) - return PreparedMarkdown(tokens, {}).acquire_inline_content(tokens) + return PreparedMarkdown(tokens, {}).acquire_inline_content( + tokens, syntax=source if isinstance(source, PreparedMarkdownPart) else None) prepared = await asyncio.to_thread(acquire_local) - if self._closing or self._pruning or self.parent is not parent: + if not current(): return None self._prepared_markdown = prepared return prepared.tokens @@ -506,17 +479,16 @@ def acquire_local(): if self._closing or self._pruning or not self.is_attached: return None raise asyncio.CancelledError - if (self._closing or not self.is_attached or self._pruning - or self.parent is not parent): + if not self.is_attached or not current(): return None def acquire(): tokens = parser.resolve_tokens(prepared.tokens) - return prepared.acquire_inline_content(tokens) + return prepared.acquire_inline_content( + tokens, syntax=markdown if isinstance(markdown, PreparedMarkdownPart) else None) prepared = await asyncio.to_thread(acquire) tokens = prepared.tokens - if (self._closing or not self.is_attached or self._pruning - or self.parent is not parent): + if not self.is_attached or not current(): return None if theme != (self.app.native_ansi_color, self.app.current_theme.dark): continue diff --git a/src/toad/widgets/streaming_markdown.py b/src/toad/widgets/streaming_markdown.py index 6376c7678..d6ed591cd 100644 --- a/src/toad/widgets/streaming_markdown.py +++ b/src/toad/widgets/streaming_markdown.py @@ -17,18 +17,21 @@ from textual.widgets import Markdown from toad.widgets.viewport_body import ChildBody, MaterializingBody, PreparedDocumentBody -from toad.widgets.prepared_markdown import PreparedContentRange, PreparedConversationMarkdown +from toad.widgets.prepared_markdown import PreparedConversationMarkdown from toad.widgets.committed_presentation import SnapshotPresentation from toad.conversation_markdown import ConversationMarkdown, _ThreadLocalPathParser from toad.render_tasks import MarkdownPartsTask, MarkdownRenderTask, MarkdownSyntaxRenderTask -from toad.markdown_preparation import PreparedMarkdownPart +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdownPart from toad.widgets.presentation_window import PresentationBudget, protected_presentations -class StreamingMarkdown(PreparedContentRange, SnapshotPresentation, PreparedConversationMarkdown): +class StreamingMarkdown(SnapshotPresentation, PreparedConversationMarkdown): def __init__(self, markdown: str | None = None, *, paginate: bool = True, - prefix: tuple[Widget, ...] = (), **kwargs) -> None: + prefix: tuple[Widget, ...] = (), + prepared_content: PreparedContentRange | None = None, **kwargs) -> None: self._paginate = paginate + self.prepared_content = prepared_content if prepared_content is not None else PreparedContentRange() + self._fragment_views = () self._prefix = prefix super().__init__(markdown, **kwargs) self._stream: MarkdownStream | None = None @@ -39,6 +42,43 @@ def __init__(self, markdown: str | None = None, *, paginate: bool = True, def compose(self) -> ComposeResult: yield from self._prefix + yield from self.prepared_content.compose(self) + + @property + def fragments(self): + return self.prepared_content.fragments + + @property + def start(self): + return self.prepared_content.start + + @property + def stop(self): + return self.prepared_content.stop + + @property + def batch_size(self): + return self.prepared_content.batch_size + + @property + def fragment_views(self): + return self._fragment_views + + def _retain_fragment_source(self, fragment, body): + return body.prepared_source + + def retain_sources(self): + self.prepared_content.retain_sources(self) + return self.prepared_content + + def retain_transcript_source(self, fragment): + fragment.prepared_content = self.retain_sources() + + def _initialize_document(self, markdown): + if self.fragments: + self.publish_body(self.materialize_native_body).call_when_ready(self) + return AwaitComplete.nothing() + return super()._initialize_document(markdown) def live_body_measurement(self, width=0, rows=0, widgets=1): # Partitioned conversation syntax owns a range of original documents. @@ -47,9 +87,11 @@ def live_body_measurement(self, width=0, rows=0, widgets=1): return ChildBody(width, rows, widgets) return super().live_body_measurement(width, rows, widgets) - def _body(self, fragment: PreparedMarkdownPart) -> PreparedConversationMarkdown: - return PreparedConversationMarkdown(fragment.text, markdown_part=fragment, - classes="-message-fragment") + def _body(self, fragment, index) -> PreparedConversationMarkdown: + source = self.prepared_content.acquired(index) + return (fragment if source is None else source).body( + PreparedConversationMarkdown, content_owner=self.prepared_content, + classes="-message-fragment") @property def retained_source_bytes(self) -> int: @@ -105,12 +147,20 @@ async def _prepare_parts(self, parts, current: Callable[[], bool]) -> bool: for first in range(0, len(parts), self.batch_size): if not current(): return False - await asyncio.gather(*(self.app.render_processes.prepare(MarkdownRenderTask( - part, self.app.native_ansi_color, self.app.current_theme.dark, - )) for part in parts[first:first + self.batch_size])) + await asyncio.gather(*(part.prepare(self.app.render_processes, self.app.native_ansi_color, + self.app.current_theme.dark) + for part in parts[first:first + self.batch_size])) return current() async def materialize_native_body(self) -> None: + if self.partitionable_syntax and self.fragments: + current = lambda: self.is_attached and not self._closing + previous = {self.start + index: child for index, child in enumerate(self.fragment_views)} + await self.prepared_content.replace_range( + self, self.fragments, slice(self.start, self.stop), previous, current, prefix=self._prefix) + self._markdown = self._pending_source + self.loading = False + return await self._update_content(self.acquired_source(self._pending_source), "", append=False) async def materialize_interactive_body(self): @@ -138,8 +188,13 @@ def retire_body_resources(self) -> None: super().retire_body_resources() def update(self, markdown: str) -> AwaitComplete: + # A whole-source request owns a fresh range even when text is equal. + # Existing controls keep their native lifetime, but cannot certify the + # new source. Append retains unchanged earlier part acquisitions. + self.prepared_content = self.prepared_content.independent() source = self._request_source(markdown, append=False) - return self.publish_body(partial(self._update_content, self.acquired_source(source), markdown, append=False)) + return self.publish_body(partial(self._update_content, self.acquired_source(source), markdown, + append=False, replace_source=True)) def append(self, markdown: str) -> AwaitComplete: source = self._request_source(markdown, append=True) @@ -155,7 +210,8 @@ async def _parse_tokens( return None return tokens - async def _update_content(self, source: str | PreparedMarkdownPart, text: str, *, append: bool) -> None: + async def _update_content(self, source: str | PreparedMarkdownPart, text: str, *, append: bool, + replace_source: bool = False) -> None: if self._closing: return self._content_generation += 1 @@ -167,14 +223,14 @@ def is_current() -> bool: and generation == self._content_generation) try: - await self._publish_content(source, text, append, is_current) + await self._publish_content(source, text, append, is_current, replace_source=replace_source) except asyncio.CancelledError: if generation == self._content_generation: self._content_generation += 1 raise async def _publish_content(self, acquired: str | PreparedMarkdownPart, text: str, append: bool, - is_current: Callable[[], bool]) -> None: + is_current: Callable[[], bool], *, replace_source: bool = False) -> None: source = acquired.text if isinstance(acquired, PreparedMarkdownPart) else acquired async with self._content_lock: if not is_current(): @@ -188,14 +244,15 @@ async def _publish_content(self, acquired: str | PreparedMarkdownPart, text: str if self.partitionable_syntax: fragments = (await self.app.render_processes.submit(MarkdownPartsTask(acquired)) if self._paginate else + (acquired,) if isinstance(acquired, PreparedMarkdownPart) else (await self.app.render_processes.submit(MarkdownSyntaxRenderTask(acquired)),)) if not is_current(): return if not self.partitionable_syntax: if self.fragments: await self.remove_children(self.fragment_views) - self.fragments, self._fragment_views = (), () - self.start = self.stop = 0 + self.prepared_content.fragments, self._fragment_views = (), () + self.prepared_content.select_admission(slice(0, 0)) self._needs_full_markdown_update = True # Custom grammar/factory behavior belongs to this original # native owner, including its block callbacks and root prefix. @@ -214,9 +271,9 @@ async def _publish_content(self, acquired: str | PreparedMarkdownPart, text: str if self.fragments and source == self.source: selected = slice(self.start, self.stop) elif self.fragments: - selected = self.update_slice(fragments, window.follows_tail) + selected = self.prepared_content.update_slice(fragments, window.follows_tail) else: - selected = self.initial_slice(fragments, self.batch_size, True) + selected = self.prepared_content.initial_slice(fragments, self.batch_size, True) if not await self._prepare_parts(fragments[selected], is_current): return previous = {self.start + index: child @@ -225,15 +282,15 @@ async def _publish_content(self, acquired: str | PreparedMarkdownPart, text: str if not is_current(): return if (selected.start <= index < selected.stop - and child._markdown_part != fragments[index]): - await child.update_part(fragments[index]) + and (replace_source or child._markdown_part != fragments[index])): + await child.update_part(fragments[index], content_owner=self.prepared_content) async with window.preserve_history(None, root=self): if not is_current(): return if not self.fragments: await self.remove_children(child for child in self.children if child not in self._prefix) - if not await self.replace_range( - fragments, selected, previous, is_current, prefix=self._prefix, + if not await self.prepared_content.replace_range( + self, fragments, selected, previous, is_current, prefix=self._prefix, ): return self._markdown = source @@ -278,8 +335,8 @@ def current(): and window.document_viewport.requires_body(self) and (not latest or (window.follows_tail and window.document_viewport.source_tail is self))) - selected = (self.initial_slice(self.fragments, self.batch_size, True) if latest - else self.extension_slice(older)) + selected = (self.prepared_content.initial_slice(self.fragments, self.batch_size, True) if latest + else self.prepared_content.extension_slice(older)) if not await self._prepare_parts(self.fragments[selected], current): return False async with window.preserve_history(None, root=self): @@ -288,11 +345,11 @@ def current(): if latest: previous = {self.start + index: child for index, child in enumerate(self.fragment_views)} - if not await self.replace_range( - self.fragments, selected, previous, current, prefix=self._prefix, + if not await self.prepared_content.replace_range( + self, self.fragments, selected, previous, current, prefix=self._prefix, ): return False - elif not await self.extend(older, current, prefix=self._prefix): + elif not await self.prepared_content.extend(self, older, current, prefix=self._prefix): return False visible = self.screen._compositor.visible_widgets protected = protected_presentations(self.fragment_views, self.screen._interaction_widgets()) @@ -306,7 +363,7 @@ def current(): break removable += 1 if removable: - self.trim(removable, older=not older) + self.prepared_content.trim(self, removable, older=not older) return True @property diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index 54b7c07d0..509cf863d 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -2,6 +2,7 @@ from toad.widgets.message_filter import ToolCategory from typing import Iterable +from contextlib import asynccontextmanager from textual import on from textual import events @@ -46,6 +47,13 @@ def output(self) -> ToolOutput: def reconstructible_children(self) -> tuple[Widget, ...]: return tuple(self.children) + @asynccontextmanager + async def retirement_custody(self): + async with super().retirement_custody() as current: + if current: + self.output.retain_sources() + yield current + def retire_body_resources(self) -> None: self.output.retire() self._mounted_parts = None diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 21718b2f6..df1547928 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -12,7 +12,7 @@ ) from toad.render_backend import ReusableRenderTask -from toad.markdown_preparation import PreparedMarkdownPart +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdown, PreparedMarkdownPart from toad.acp.status import ToolCallStatus from toad.widgets.agent_activity import AgentActivityBoundary from toad.widgets.message_filter import event_category, keep_events @@ -146,37 +146,83 @@ def select(first, stop, token_first, token_stop): yield select(pending_first, pending_stop, pending_token_first, pending_token_stop) -@dataclass(frozen=True) +@dataclass class TranscriptFragment: events: tuple[TranscriptEvent, ...] continuation: bool = False starts_agent_activity: bool = False markdown_part: PreparedMarkdownPart | None = field(default=None, kw_only=True) + prepared_source: PreparedMarkdown | None = field(default=None, kw_only=True, compare=False, repr=False) + prepared_content: PreparedContentRange | None = field(default=None, kw_only=True, compare=False, repr=False) def blocks(self, *, fragment: bool = False, show_divider: bool = True): from toad.widgets.transcript_history import TranscriptBlockConsumer consumer = TranscriptBlockConsumer(fragment=fragment, show_divider=show_divider, - markdown_part=self.markdown_part) + markdown_part=self.markdown_part, + prepared_source=self.prepared_source, prepared_content=self.prepared_content) for event in self.events: consumer.dispatch_sync(event) return consumer.blocks + def retain_source(self, view) -> None: + """The source slot takes its original semantic producer's acquisition.""" + for block in view.children: + block.retain_transcript_source(self) + + def resolved_sources(self): + return (() if self.prepared_source is None else self.prepared_source.resolved_sources()) + ( + () if self.prepared_content is None else self.prepared_content.resolved_sources()) + + def independent(self) -> "TranscriptFragment": + """A separate projection owns independently resolved source lifetimes.""" + return self._independent(prepared_source=None, prepared_content=None) + + def release_source(self): + self.prepared_source = None + self.prepared_content = None + return self + + def _independent(self, **changes): + source = replace(self, **changes) + # Measurement belongs to immutable input; acquisitions never enter it. + source.__dict__["retained_bytes"] = self.retained_bytes + return source + @cached_property def retained_bytes(self) -> int: - """Measure this immutable source resource once, before native admission.""" + """Worker-owned immutable input cost, never an acquisition graph.""" from toad.work_preparation import retained_bytes return retained_bytes(self) -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True) class ToolTranscriptFragment(TranscriptFragment): """One original grouped tool and its worker-acquired ACP presentation.""" tool_call: ToolCallStatus output_parts: "tuple[ToolOutputPart, ...]" + def retain_source(self, view): + from toad.widgets.tool_call import ToolCall + + # ToolCall owns the original decoded parts, each of which binds its + # own composition. There is no first-Markdown-descendant inference. + tool = next(iter(view.query_children(ToolCall)), None) + if tool is not None: + self.output_parts = tool.output.retain_sources() + + def resolved_sources(self): + return tuple(source for part in self.output_parts for source in part.resolved_sources()) + + def independent(self): + return self._independent(output_parts=tuple(part.admit() for part in self.output_parts)) + + def release_source(self): + self.output_parts = tuple(part.admit() for part in self.output_parts) + return self + def blocks(self, *, fragment: bool = False, show_divider: bool = True): from toad.acp.encode_tool_call_id import encode_tool_call_id from toad.widgets.tool_call import ToolCall @@ -185,6 +231,31 @@ def blocks(self, *, fragment: bool = False, show_divider: bool = True): output_parts=self.output_parts)] +@dataclass(kw_only=True) +class ContextTranscriptFragment(TranscriptFragment): + """One original lazy disclosure and its two distinct source ranges.""" + + formatted: str | None = field(default=None, compare=False, repr=False) + original_content: PreparedContentRange | None = field(default=None, compare=False, repr=False) + + def blocks(self, *, fragment=False, show_divider=True): + from toad.widgets.coordination_context import CoordinationContext + + return [CoordinationContext(self.events[0].text, source=self)] + + def resolved_sources(self): + return super().resolved_sources() + (() if self.original_content is None else + self.original_content.resolved_sources()) + + def independent(self): + return self._independent(prepared_content=None, original_content=None) + + def release_source(self): + super().release_source() + self.original_content = None + return self + + class TranscriptFragmentConsumer(MroDispatch): def __init__(self, *, continuation: bool = False, split_text: bool = True): self.fragments: list[TranscriptFragment] = [] @@ -197,7 +268,7 @@ def __init__(self, *, continuation: bool = False, split_text: bool = True): @handles(ContextTranscript) def context(self, event: ContextTranscript): # One lazy disclosure owns the full metadata source. - self.fragments.append(TranscriptFragment((event,), continuation=self.continuation)) + self.fragments.append(ContextTranscriptFragment((event,), continuation=self.continuation)) @handles(UserTranscript, MarkdownTranscript) def text(self, event: TextTranscript): diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 8e72a340b..ed0a85e83 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -36,8 +36,8 @@ ProjectedTranscriptSource, ) from toad.response_delivery import ResponseDelivery -from toad.widgets.prepared_markdown import PreparedContentRange -from toad.markdown_preparation import PreparedMarkdownPart +from toad.markdown_preparation import PreparedContentRange +from toad.markdown_preparation import PreparedMarkdown, PreparedMarkdownPart from toad.widgets.agent_response import AgentResponse from toad.widgets.agent_thought import AgentThought from toad.widgets.tool_call import ToolCall @@ -62,21 +62,21 @@ class _PublicationRetired(Exception): class TranscriptBlockConsumer(MroDispatch): def __init__(self, *, fragment: bool, show_divider: bool, - markdown_part: PreparedMarkdownPart | None = None): + markdown_part: PreparedMarkdownPart | None = None, + prepared_source: PreparedMarkdown | None = None, + prepared_content: PreparedContentRange | None = None): self.blocks: list[Widget] = [] self.fragment = fragment self.show_divider = show_divider self.markdown_part = markdown_part - - @handles(ContextTranscript) - def context(self, event: ContextTranscript): - from toad.widgets.coordination_context import CoordinationContext - self.blocks.append(CoordinationContext(event.text)) + self.prepared_source = prepared_source + self.prepared_content = prepared_content @handles(UserTranscript) def user(self, event: UserTranscript): self.blocks.append(UserInput(event.text, claim=TranscriptInputClaim(event), markdown_part=self.markdown_part, + prepared_source=self.prepared_source, show_divider=self.show_divider, clock=MessageClock.recorded(event.timestamp))) @@ -84,13 +84,17 @@ def user(self, event: UserTranscript): def incoming(self, event: IncomingTranscript): from toad.widgets.incoming_message import IncomingMessage self.blocks.append(IncomingMessage(event, show_header=self.show_divider, - markdown_part=self.markdown_part)) + markdown_part=self.markdown_part, + prepared_content=self.prepared_content, + paginate=not self.fragment)) @handles(SentTranscript) def sent(self, event: SentTranscript): from toad.widgets.outgoing_message import OutgoingMessage self.blocks.append(OutgoingMessage(event, show_header=self.show_divider, - markdown_part=self.markdown_part)) + markdown_part=self.markdown_part, + prepared_content=self.prepared_content, + paginate=not self.fragment)) @handles(AgentTextTranscript) def agent(self, event: AgentTextTranscript): @@ -98,13 +102,15 @@ def agent(self, event: AgentTextTranscript): event.text, delivery=ResponseDelivery.from_route(event.routing.reply if event.routing else None), category=event_category(event), paginate=not self.fragment, markdown_part=self.markdown_part, + prepared_content=self.prepared_content, show_divider=self.show_divider, clock=MessageClock.recorded(event.timestamp), )) @handles(ThinkingTranscript) def thinking(self, event: ThinkingTranscript): self.blocks.append(AgentThought(event.text, paginate=not self.fragment, - markdown_part=self.markdown_part)) + markdown_part=self.markdown_part, + prepared_content=self.prepared_content)) def transcript_blocks(events: tuple[TranscriptEvent, ...], *, fragment: bool = False, show_divider: bool = True) -> list[Widget]: @@ -237,7 +243,7 @@ class TranscriptPageAdmission: start: int stop: int -class TranscriptPageView(PreparedContentRange, VerticalGroup): +class TranscriptPageView(VerticalGroup): CACHE_SUBTREE_GEOMETRY = True def __init__(self, page: TranscriptPage, *, newest: bool = True, @@ -245,16 +251,13 @@ def __init__(self, page: TranscriptPage, *, newest: bool = True, batch_size: int = PreparedContentRange.BATCH, prepared: PreparedTranscriptPage | None = None): self.prepared = prepared if prepared is not None else PreparedTranscriptPage( - page, transcript_fragments(page.events) if fragments is None else fragments, 0, + page, transcript_fragments(page.events) if fragments is None else + tuple(fragment.independent() for fragment in fragments), 0, ) - admission = self.prepared.admission self.visible_categories = all_categories() - super().__init__( - fragments=self.prepared.fragments, - batch_size=batch_size, newest=newest, - ) - if admission is not None: - self.prepared.admission = admission + self._fragment_views = () + self.prepared.configure(batch_size=batch_size, newest=newest) + super().__init__() @property def page(self) -> TranscriptPage: @@ -277,23 +280,48 @@ def fragments(self, fragments: tuple[TranscriptFragment, ...]) -> None: @property def start(self) -> int: - return self.prepared.admission[0] + return self.prepared.start @start.setter def start(self, start: int) -> None: - admission = self.prepared.admission - self.prepared.admission = start, admission[1] if admission is not None else start + self.prepared.start = start @property def stop(self) -> int: - return self.prepared.admission[1] + return self.prepared.stop @stop.setter def stop(self, stop: int) -> None: - self.prepared.admission = self.start, stop + self.prepared.stop = stop + + @property + def batch_size(self): + return self.prepared.batch_size + + @batch_size.setter + def batch_size(self, size): + self.prepared.batch_size = size + + @property + def fragment_views(self): + return self._fragment_views + + def compose(self): + yield from self.prepared.compose(self) - def _body(self, fragment: TranscriptFragment) -> TranscriptFragmentView: - return TranscriptFragmentView(fragment, self.visible_categories) + def on_unmount(self): + self._fragment_views = () + + def _retain_fragment_source(self, fragment, body): + # A source update may retain an unchanged native member while replacing + # the prepared inputs. Custody follows that actual admitted member. + source = body.fragment + source.retain_source(body) + return source + + def _body(self, fragment: TranscriptFragment, index: int) -> TranscriptFragmentView: + source = self.prepared.acquired(index) + return TranscriptFragmentView(fragment if source is None else source, self.visible_categories) @classmethod @asynccontextmanager @@ -304,22 +332,22 @@ async def acquire( ) -> AsyncIterator[TranscriptPageView]: """Acquire a page until its original source transfers native custody.""" view = cls(page, fragments=fragments, prepared=prepared, - batch_size=min(batch_size, cls.BATCH), newest=newest) + batch_size=min(batch_size, PreparedContentRange.BATCH), newest=newest) # This is a new edge/End demand, not restoration of parked membership. # A projected boundary can return the same original prepared resource; # its preceding range must not override the explicitly requested edge. - selected = cls.initial_slice(view.fragments, view.batch_size, newest) - view.start, view.stop = selected.start, selected.stop + selected = PreparedContentRange.initial_slice(view.fragments, view.batch_size, newest) + view.prepared.select_admission(selected) view.visible_categories = owner.selected_categories with ExitStack() as acquisition: acquisition.callback(owner.remove_children, (view,)) await owner.mount(view, before=before) if not current(): raise _PublicationRetired - selected = cls.initial_slice(fragments, batch_size, newest) + selected = PreparedContentRange.initial_slice(fragments, batch_size, newest) if selected != slice(view.start, view.stop): previous = {view.start + index: child for index, child in enumerate(view.fragment_views)} - if not await view.replace_range(fragments, selected, previous, current): + if not await view.prepared.replace_range(view, fragments, selected, previous, current): raise _PublicationRetired view.batch_size = batch_size yield view @@ -330,12 +358,17 @@ def capture_admission(self) -> TranscriptPageAdmission: CommittedInterval(self.page.before, self.page.after), self.start, self.stop, ) + def retain_sources(self) -> None: + """Only the original admitted source range survives native disposal.""" + self.prepared.retain_sources(self) + self.prepared.retained_bytes = 0 + def restore_admission(self, admission: TranscriptPageAdmission) -> None: # Positions refer to this immutable native interval, not to whatever # newer snapshot happened to be published during an inactive turn. if admission.interval != CommittedInterval(self.page.before, self.page.after): return - self.start, self.stop = admission.start, admission.stop + self.prepared.select_admission(slice(admission.start, admission.stop)) def set_categories(self, selected: frozenset[type[MessageCategory]]) -> None: self.visible_categories = selected @@ -372,8 +405,8 @@ async def update_fragments( if not current() or self.capture_admission() != admission: return False async with window.preserve_history(None, root=self): - if not await self.replace_range( - fragments, selected, previous, + if not await self.prepared.replace_range( + self, fragments, selected, previous, lambda: current() and self.capture_admission() == admission, ): return False @@ -395,7 +428,7 @@ def __init__(self, page: TranscriptPage, loader: Callable[..., Awaitable[Transcr super().__init__(source_state=LiveTranscript() if committed else ProvisionalTranscript()) self.loader, self.through = loader, page.after self.budget = budget or PresentationBudget( - max_items=self.MAX_FRAGMENTS, admission_items=TranscriptPageView.BATCH, + max_items=self.MAX_FRAGMENTS, admission_items=PreparedContentRange.BATCH, ) self.pages = deque([TranscriptPageView( page, fragments=fragments, prepared=prepared, batch_size=self.budget.admission_items, @@ -409,18 +442,14 @@ def __init__(self, page: TranscriptPage, loader: Callable[..., Awaitable[Transcr async def park_document(self) -> PreparedPageSource: """Transfer original data and admission out of disposable native pages.""" - from toad.work_preparation import retained_bytes - await self.retire_source(parked=True) source = self._reader() projection = self.filter.state.overlay projected = await projection.park_document() if projection is not None else None + for page in self.pages: + page.retain_sources() pages = tuple(page.prepared for page in self.pages) - for page in pages: - if not page.retained_bytes: - page.retained_bytes = await source.runtime.run_thread( - retained_bytes, (page.page, page.fragments), - ) + await source.runtime.run_thread(source.measure_admission, pages, projected) source.park(pages, projected) return source @@ -577,8 +606,8 @@ def fragment_count(self) -> int: @property def retained_source_bytes(self) -> int: - return sum(node.retained_source_bytes for node in walk_depth_first(self, with_root=False) - if isinstance(node, ViewportBody)) + return sum(owner.retained_source_bytes for owner in self.window.document_viewport.owners + if self in owner.ancestors) def compose(self) -> ComposeResult: yield self.older @@ -693,22 +722,22 @@ def current(): # Preparation is detached. If reader intent changes while it runs, # prepare the newly selected range before borrowing the native fence. while current(): - selected = view.update_slice(fragments, window.follows_tail) + selected = view.prepared.update_slice(fragments, window.follows_tail) await self.prepare_fragments(fragments[selected], current) async with window.history_lock: if not current(): return - if selected != view.update_slice(fragments, window.follows_tail): + if selected != view.prepared.update_slice(fragments, window.follows_tail): continue async with window.preserve_history(None, root=self): self.filter.remove() - if selected != view.update_slice(fragments, window.follows_tail): + if selected != view.prepared.update_slice(fragments, window.follows_tail): continue # Body workers have their own native custody. They may publish a # nested page in the same window before this source is committed. if not await view.update_fragments( page, fragments, selected, - lambda: current() and selected == view.update_slice(fragments, window.follows_tail), + lambda: current() and selected == view.prepared.update_slice(fragments, window.follows_tail), ): continue async with window.history_lock: @@ -827,7 +856,7 @@ async def _publish_latest(self, request: LatestViewportRequest) -> bool: else: prepared = await self._reader().get(PageRequest(before=self.through)) page, fragments = prepared.page, prepared.fragments - selected = TranscriptPageView.initial_slice(fragments, destination_admission, True) + selected = PreparedContentRange.initial_slice(fragments, destination_admission, True) await self.prepare_fragments( fragments[selected], lambda: snapshot.current(self) and request.current(window), ) @@ -882,8 +911,8 @@ async def _load_page(self, older: bool) -> None: after=edge.page.after if not older else None, )) page, fragments = prepared.page, prepared.fragments - selected = (edge.extension_slice(older) if local - else TranscriptPageView.initial_slice(fragments, self.budget.admission_items, older)) + selected = (edge.prepared.extension_slice(older) if local + else PreparedContentRange.initial_slice(fragments, self.budget.admission_items, older)) await self.prepare_fragments( (edge.fragments if local else fragments)[selected], lambda: snapshot.current(self), ) @@ -923,7 +952,7 @@ async def _extend_and_trim( snapshot, admission = self.source_snapshot(), edge.capture_admission() if local: previous_children = set(edge.fragment_views) - if not await edge.extend(older, lambda: snapshot.current(self)): + if not await edge.prepared.extend(edge, older, lambda: snapshot.current(self)): raise _PublicationRetired self._require_publication() protected.update(child for child in edge.fragment_views if child not in previous_children) @@ -991,7 +1020,7 @@ async def _extend_and_trim( else: if not remove_count: break - evicted.trim(min(remove_count, count), older=side) + evicted.prepared.trim(evicted, min(remove_count, count), older=side) excess -= remove_count self.filter.canonical_moved(previous_start, overlay_visible) self._update_edges() @@ -1013,7 +1042,8 @@ def __init__(self, owner: TranscriptHistory, source: ProjectedTranscriptSource, # A slow/held row batch must not strand the page's own message pump in # Compose, where input-settlement barriers would wait on its startup. if not source.admitted: - self.pages[0].stop = self.pages[0].start + page = self.pages[0] + page.prepared.select_admission(slice(page.start, page.start)) self.add_class("filtered-history-results") @classmethod @@ -1053,7 +1083,7 @@ async def admit_initial(self) -> None: async def _admit_initial(self) -> None: self._require_publication() snapshot = self.source_snapshot() - if not await self.pages[0].extend(False, lambda: snapshot.current(self)): + if not await self.pages[0].prepared.extend(self.pages[0], False, lambda: snapshot.current(self)): raise _PublicationRetired self._require_publication() self._update_edges() diff --git a/src/toad/widgets/user_input.py b/src/toad/widgets/user_input.py index e1e168210..9e825a904 100644 --- a/src/toad/widgets/user_input.py +++ b/src/toad/widgets/user_input.py @@ -4,7 +4,7 @@ from textual.app import ComposeResult from textual import containers from toad.widgets.prepared_markdown import PreparedConversationMarkdown -from toad.markdown_preparation import PreparedMarkdownPart +from toad.markdown_preparation import PreparedMarkdown, PreparedMarkdownPart from toad.widgets.non_selectable_label import NonSelectableLabel from toad.widgets.message_divider import MessageDivider, MessageClock, LiveMessageClock @@ -47,10 +47,12 @@ def message_category(self) -> type[MessageCategory]: def __init__(self, content: str, *, claim: CommitClaim = CAPTURED_CLAIM, markdown_part: PreparedMarkdownPart | None = None, + prepared_source: PreparedMarkdown | None = None, show_divider: bool = True, clock: MessageClock = LiveMessageClock()) -> None: super().__init__() self.content = content self.markdown_part = markdown_part + self._source_transfer = prepared_source self.show_divider = show_divider self.clock = clock self.claim = claim @@ -64,7 +66,16 @@ def compose(self) -> ComposeResult: yield MessageDivider("User", clock=self.clock) with containers.HorizontalGroup(classes="user-input-body"): yield NonSelectableLabel("❯" if self.show_divider else " ", id="prompt") - yield PreparedConversationMarkdown(self.content, markdown_part=self.markdown_part, id="content") + yield PreparedConversationMarkdown(self.content, markdown_part=self.markdown_part, + prepared_source=self._source_transfer, id="content") + + @property + def prepared_source(self): + body = self.query_one_optional("#content", PreparedConversationMarkdown) + return self._source_transfer if body is None else body.prepared_source + + def retain_transcript_source(self, fragment): + fragment.prepared_source = self.prepared_source def get_clipboard_text(self) -> str | None: return self.content diff --git a/src/toad/widgets/viewport_body.py b/src/toad/widgets/viewport_body.py index 9e412c72a..a92efdbca 100644 --- a/src/toad/widgets/viewport_body.py +++ b/src/toad/widgets/viewport_body.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from textual.document._paint import DocumentPaint + from toad.markdown_preparation import PreparedMarkdown class ViewportBody: @@ -724,10 +725,10 @@ class PreparedDocumentBody(BodyMeasurement): paint: "DocumentPaint" resource_bytes: int - source_bytes: int + source_resource: "PreparedMarkdown" def source_cost(self, body): - return self.source_bytes + return self.source_resource.retained_bytes @property def document_paint(self): @@ -814,7 +815,7 @@ def style_updated(self, body): return self.invalidated() def invalidated(self, width=None): - return PendingDocumentBody(self.paint, self.resource_bytes, self.source_bytes, + return PendingDocumentBody(self.paint, self.resource_bytes, self.source_resource, self.width if width is None else width) def resized(self, size): @@ -823,7 +824,8 @@ def resized(self, size): def released(self): return MeasuredSourceBody(self.width, self.rows, self.widgets, - root_empty=self.paint.root_empty, source_bytes=self.source_bytes) + root_empty=self.paint.root_empty, + source_bytes=self.source_cost(None)) async def restore(self, body): if not self.ready(body): @@ -1300,7 +1302,7 @@ def geometry_targets(self) -> tuple[Widget, ...]: """ windows = tuple(self.frame_windows()) return tuple(dict.fromkeys(( - *(target for window in windows if window in self.anchors + *(target for window in windows for target in window.history_geometry_targets()), *(target for window in windows for target in window.document_viewport.geometry_targets()), @@ -1642,7 +1644,7 @@ async def close(self) -> None: def protected(self) -> set[Widget]: screen = self.window.screen protected = set() - for endpoint in screen._interaction_widgets(): + for endpoint in (*screen._interaction_widgets(), *self.window.reader_bodies): for node in endpoint.walk_ancestors(with_self=True): if not isinstance(node, Widget) or node is self.window: break @@ -1676,6 +1678,7 @@ async def _reconcile(self) -> None: # cohort; a ready outer fragment cannot prepare their rows. foreground = tuple(dict.fromkeys(( *required, + *self.window.reader_bodies, *(body for _window, body in screen.viewport_presentation.visible_bodies((self.window,))), ))) for owner in foreground: diff --git a/src/toad/work_preparation.py b/src/toad/work_preparation.py index a9416a607..80e81c4ed 100644 --- a/src/toad/work_preparation.py +++ b/src/toad/work_preparation.py @@ -217,10 +217,16 @@ def store_result(self, result: ResultT) -> PreparedValue[ResultT]: return self.task.preparation_storage.store_result(self, result) -def retained_bytes(value: object) -> int: - """Bound retained model graphs, including nested tool inputs, off-loop.""" +def retained_bytes(value: object, *, seen: set[int] | None = None) -> int: + """Measure retained graphs off-loop, sharing custody within one traversal. + + Related resource owners can partition their original graphs with the same + local visited set. Shared source data is then charged once, without keeping + another cache or walking those objects again for each resource. + """ pending = [value] - seen: set[int] = set() + if seen is None: + seen = set() total = 0 while pending: item = pending.pop() diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 4f1cc712f..f1bdd9153 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -78,13 +78,11 @@ def matches(self, view): return self.view is view async def retire(self): - # Retire this source's focus before hiding it. Hide's native fallback - # otherwise searches the shared frame for an intermediate target while - # the destination is still being admitted. Shared chrome keeps focus. - if self.view.has_focus_within: - self.view.screen.set_focus(None) - self.view.display = False + # Source retirement captures the departing reader from its committed + # scene. Blurring or hiding first changes the actual CSS admission and + # discards the document's source placement before its owner can take it. await self.view.retire_presentation() + self.view.display = False return ParkedWorkspaceSource(self.view) def commands(self): diff --git a/tests/block_interaction_pilot.py b/tests/block_interaction_pilot.py index e08c7cfe1..d2d6a7cd6 100644 --- a/tests/block_interaction_pilot.py +++ b/tests/block_interaction_pilot.py @@ -6,6 +6,7 @@ import subprocess import tempfile +from agent_comms.transcript_events import ContextTranscript from textual.widgets import Static from toad.block_content import BlockContent from toad.block_navigation import ConversationBlock @@ -14,7 +15,7 @@ from toad.widgets.menu import Menu from toad.widgets.tool_call import ToolCall from toad.widgets.user_input import UserInput -from toad.widgets.coordination_context import CoordinationContext +from toad.widgets.transcript_fragments import transcript_fragments from runtime_fixture import ToadApp @@ -129,7 +130,7 @@ async def copied(widget, text): assert "DECLARED_ACTION_PAINT" in frame(app) assert view.check_action("expand_block", ()) is None assert view.check_action("collapse_block", ()) is False - context = CoordinationContext("CONTEXT_COPY_PAINT") + context = transcript_fragments((ContextTranscript("CONTEXT_COPY_PAINT"),))[0].blocks()[0] await view.post(context) await pilot.pause() assert view.navigation.select(context) diff --git a/tests/coordination_context_pilot.py b/tests/coordination_context_pilot.py index 241ac7ead..9064f6ac9 100644 --- a/tests/coordination_context_pilot.py +++ b/tests/coordination_context_pilot.py @@ -91,7 +91,7 @@ async def main(): {"name": "peer-a", "status": "running", "activity": "working", "activity_detail": "Read | review"}, {"name": "peer-b", "status": "stopped", "activity": "idle", "activity_detail": "Done"}, ]) + "\n\nReview the changes.") - readable = await view.post(CoordinationContext(raw)) + readable = await view.post(transcript_fragments((ContextTranscript(raw),))[0].blocks()[0]) await pilot.pause() assert readable.collapsed and not readable.query(AgentResponse) readable.expand_block() diff --git a/tests/native_widget_actions_installed_pilot.py b/tests/native_widget_actions_installed_pilot.py index 35d312544..61c56bd1c 100644 --- a/tests/native_widget_actions_installed_pilot.py +++ b/tests/native_widget_actions_installed_pilot.py @@ -6,13 +6,14 @@ from pathlib import Path from acp import schema +from agent_comms.transcript_events import ContextTranscript from textual.binding import Binding from toad.answer import Answer from toad.application_actions import KeyboundAction from toad.question_actions import QuestionAction from toad.screens.permissions import PermissionsScreen from toad.widgets.question import Question, Option -from toad.widgets.coordination_context import CoordinationContext +from toad.widgets.transcript_fragments import transcript_fragments from toad.widgets.flash import Flash from runtime_fixture import ToadApp from l0a_native_installed_pilot import main, until, response_painted @@ -55,7 +56,7 @@ async def acceptance(app, pilot, agent, comms, entered, release, hold_next, requ await pilot.press('escape') assert not comms.registry.require('beta').executing - disclosure = await view.post(CoordinationContext('ACTION_CONTEXT_VISIBLE')) + disclosure = await view.post(transcript_fragments((ContextTranscript('ACTION_CONTEXT_VISIBLE'),))[0].blocks()[0]) await pilot.pause() # Native block-cursor key establishes the real history focus owner. view.focus_prompt(scroll_end=False)