diff --git a/src/toad/block_content.py b/src/toad/block_content.py index c758ab1fb..fc69415cc 100644 --- a/src/toad/block_content.py +++ b/src/toad/block_content.py @@ -1,5 +1,8 @@ -"""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 from toad.menus import MenuItem @@ -7,6 +10,15 @@ 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 + + return AtomicBlockCursor(self) + def get_block_menu(self) -> Iterable[MenuItem]: return () @@ -30,13 +42,134 @@ 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 + 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 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 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]: + """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/block_navigation.py b/src/toad/block_navigation.py index 0ad62b97c..63075b241 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,65 @@ 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 + + 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 + + def scroll_to_center(self, window) -> None: + pass + + def export_render(self): + return None class AtomicBlockCursor(BlockCursor): @@ -53,34 +109,319 @@ 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): + return self._enter_from(direction.entry(len(self.block.displayed_children)), direction) + + def move(self, direction): + 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): + 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): + """One source selection across detached paint and actual native controls.""" + + def __init__(self, block): + super().__init__(block) + 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): + sources = self._current_sources() + if sources is None: + return None + direction = self._selection + if not self._enter_from(sources, direction.entry(len(sources)), direction): + return None + 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. + sources = self._current_sources() + if sources is None: + return original + for source in sources: + if self._same_member(source, original): + self._selection = source + return source + self._selection = None + return None + + @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 + + @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): - self.index = direction.entry(len(self.block.displayed_children)) + if self.block._closing: + return False + 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(sources, direction.entry(len(sources)), direction) def move(self, direction): - self.index += direction.step - return self.selected + source = self.selected + if source is None: + return isinstance(self._selection, CursorDirection) + 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 = 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): - self.index = self.block.displayed_children.index(widget) + 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 self._same_member(source, owner)) + + 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): + 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: + 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 + + 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 + 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 +434,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 +442,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 48f82dcd7..63d8c7db5 100644 --- a/src/toad/conversation_markdown.py +++ b/src/toad/conversation_markdown.py @@ -14,16 +14,18 @@ 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"]): - def get_clipboard_text(self) -> str: - return self._content.plain +class ConversationCodeFence(MarkdownFence): + @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( @@ -201,7 +203,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..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 - def acquire_inline_content(self, tokens: list[Token]) -> "PreparedMarkdown": + @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 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,13 +188,18 @@ 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] + 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: + +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"}: @@ -135,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 fe9b13391..0a7fcbcea 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 @@ -17,6 +19,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 +62,42 @@ 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 MarkdownRenderTask(ReusableRenderTask[PreparedMarkdown]): +class MarkdownSyntaxRenderTask(MarkdownSourcePreparation, ReusableRenderTask[PreparedMarkdownPart]): + """Acquire one complete syntax resource when the owner forbids paging.""" + + source: str | PreparedMarkdownPart + + def execute(self) -> PreparedMarkdownPart: + part = self.acquire_source() + part.retained_bytes + return part + + 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(MarkdownSourcePreparation, ReusableRenderTask[PreparedMarkdown]): """Acquire syntax once, then highlight before independent delivery.""" source: str | PreparedMarkdownPart @@ -76,9 +114,8 @@ 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) - return prepare_tokens(part.acquire_tokens(), self.ansi, self.dark) + part = self.acquire_source() + return prepare_tokens(part.acquire_tokens(), self.ansi, self.dark, syntax=part) def accept_result(self, result: object) -> PreparedMarkdown: if not isinstance(result, PreparedMarkdown): @@ -87,23 +124,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 +144,33 @@ 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 + root_selection: Selection | None = None + selection_style: Style | None = None + selecting: bool = False + + def execute(self) -> "DocumentPaint": + 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, 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 + + @dataclass(frozen=True) class RichRenderTask(ReusableRenderTask[PreparedRichContent]): source: RichSource 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/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/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/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 ce87b57d1..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 @@ -28,11 +29,23 @@ ) -@dataclass(frozen=True) -class PreparedTranscriptPage: +@dataclass +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) @@ -90,6 +103,48 @@ 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 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: + 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 +160,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 +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) + return replace(page, fragments=tuple(fragment.independent() for fragment in matches.fragments), + admission=None, admitted=()) @dataclass(frozen=True) @@ -177,9 +235,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,13 +248,15 @@ 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 + return result.admit() async def prefetch( self, before: TranscriptCursor | None, after: TranscriptCursor | None, @@ -266,6 +327,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 +428,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/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 8ae3fa588..5e446f5a6 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" @@ -344,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 @@ -387,7 +390,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) @@ -440,15 +442,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 +504,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: @@ -679,7 +684,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) @@ -692,9 +697,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) @@ -856,7 +863,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) @@ -948,7 +955,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): @@ -1273,9 +1280,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) @@ -1808,7 +1819,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. @@ -1967,7 +1978,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) @@ -2084,7 +2095,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() @@ -2095,7 +2106,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 = [ @@ -2104,46 +2116,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 @@ -2171,17 +2181,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/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 cd79de943..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,9 +18,11 @@ 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 + from toad.widgets.transcript_fragments import TranscriptFragment class WindowRestoration(ABC): @@ -29,11 +31,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 +65,82 @@ 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 + records = tuple(window.visible_history_items( + fragment for history in histories for fragment in history.fragment_views )) + 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), + 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) + + 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): - def _restore(self, window: "HistoryWindow") -> None: + def _restore(self, window: "HistoryWindow") -> bool: window.anchor() + return True @dataclass(frozen=True) @@ -104,12 +148,114 @@ 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") -> bool: + return self._restore_row(window, self.y) - def _restore(self, window: "HistoryWindow") -> None: - window.release_anchor() - window.scroll_to(y=self.y, animate=False, immediate=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 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 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) + 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 + 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): @@ -167,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 @@ -297,6 +454,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 +610,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 @@ -447,10 +618,11 @@ def _offset(widget: Widget, window: Widget) -> int: # 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: + 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 +633,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 +643,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 +655,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/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/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/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 ed918074d..2cc75ad2c 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -1,9 +1,9 @@ """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 partial +from functools import cached_property, partial from typing import cast from markdown_it import MarkdownIt @@ -12,142 +12,38 @@ 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 from textual.widgets import Label from textual.widgets._markdown import Markdown, MarkdownBlock 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.render_tasks import MarkdownRenderTask -from toad.widgets.viewport_body import MeasuredViewportBody +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 +from toad.widgets.viewport_body import MeasuredViewportBody, MeasuredSourceBody, PreparedDocumentBody 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 +class PreparedMarkdownContent: + """Native blocks keep token/link custody; WorkerStatic owns their wrapping.""" - 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, + @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, ) - - 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(WorkerStatic): - """Native blocks keep token/link custody; WorkerStatic owns their wrapping.""" + cls._require_document_methods({"render_line": WorkerStatic.render_line}) + return cls.native_document_node(block) @asynccontextmanager async def preparation_publication(self, *, layout: bool): @@ -167,34 +63,60 @@ 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(PreparedMarkdownContent, Label, WorkerStatic): + """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) + + @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 = """ PreparedConversationMarkdown.-message-fragment { @@ -210,11 +132,68 @@ 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, } + @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.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)): + return None + return paint + 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 @@ -227,9 +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._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) @@ -244,11 +234,14 @@ 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 - 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) @@ -261,60 +254,214 @@ 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) + 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: - """Release reconstructible preparation with the native retirement.""" - self._prepared_markdown = None + """Native controls retire; acquired source belongs to this document. - async def materialize_native_body(self) -> None: - await self._update_body_source(self.source) + 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): + if not self.owns_requested_source(): + return False + source = self._pending_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(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. + 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): + 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)) + 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 _update_body_source(self, markdown: str) -> AwaitComplete: - if self._markdown_part is not None and self._markdown_part.text != markdown: - self._markdown_part = None - return super().update(markdown) + def _request_source(self, markdown: str, *, append: bool) -> str: + """Accept a source request once, before its publication can await. - def _append_body_source(self, markdown: str) -> AwaitComplete: - if self._markdown_part is not None and self._markdown_part.text != self.source + markdown: - self._markdown_part = None - return super().append(markdown) + 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 + # 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 + + 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, + # 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, source, 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 + 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, + root_empty=self.is_empty) + app = self.app + 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)) + paint = paint.with_presentation( + document, root_selection=task.root_selection, + selection_style=task.selection_style, selecting=task.selecting, + ) + def resource_costs(): + 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) + 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) + + 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 on_unmount(self) -> None: self._prepared_markdown = None + self.document = None 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 + 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) if tokens is not None: 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 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)) - 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 @@ -324,7 +471,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() @@ -332,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 @@ -352,8 +498,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 +521,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) diff --git a/src/toad/widgets/streaming_markdown.py b/src/toad/widgets/streaming_markdown.py index dd8b0aed6..d6ed591cd 100644 --- a/src/toad/widgets/streaming_markdown.py +++ b/src/toad/widgets/streaming_markdown.py @@ -14,44 +14,131 @@ 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 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 _ThreadLocalPathParser -from toad.render_tasks import MarkdownPartsTask, MarkdownRenderTask -from toad.markdown_preparation import PreparedMarkdownPart -from toad.widgets.transcript_fragments import RenderBudget +from toad.conversation_markdown import ConversationMarkdown, _ThreadLocalPathParser +from toad.render_tasks import MarkdownPartsTask, MarkdownRenderTask, MarkdownSyntaxRenderTask +from toad.markdown_preparation import PreparedContentRange, PreparedMarkdownPart from toad.widgets.presentation_window import PresentationBudget, protected_presentations -from toad.widgets.viewport_body import MaterializingBody -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 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: 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 - def _body(self, fragment: PreparedMarkdownPart) -> PreparedConversationMarkdown: - return PreparedConversationMarkdown(fragment.text, markdown_part=fragment, - classes="-message-fragment") + @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. + # 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, 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: - 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 + 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) @@ -60,13 +147,28 @@ 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: - await self._update_content(self.source, append=False) + 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): + # 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): @@ -86,13 +188,20 @@ 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)) + # 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, replace_source=True)) 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) @@ -101,14 +210,12 @@ 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, + replace_source: bool = False) -> 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: @@ -116,15 +223,15 @@ 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 - self._pending_source = self.source raise - async def _publish_content(self, source: str, text: str, append: bool, - is_current: Callable[[], bool]) -> None: + async def _publish_content(self, acquired: str | PreparedMarkdownPart, text: str, append: bool, + 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(): return @@ -133,28 +240,28 @@ 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 = (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: + 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 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.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. + # 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 @@ -164,9 +271,9 @@ async def _publish_content(self, source: str, text: str, append: bool, 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 @@ -175,15 +282,15 @@ async def _publish_content(self, source: str, text: str, append: bool, 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 @@ -228,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): @@ -238,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()) @@ -256,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 16f52783f..ed0a85e83 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -29,22 +29,22 @@ 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, 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 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 ( @@ -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]: @@ -149,6 +155,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 +163,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) @@ -233,40 +243,111 @@ 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, fragments: tuple[TranscriptFragment, ...] | None = None, - batch_size: int = PreparedContentRange.BATCH): - self.page = page - self.visible_categories = all_categories() - super().__init__( - fragments=transcript_fragments(page.events) if fragments is None else fragments, - batch_size=batch_size, newest=newest, + 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 + tuple(fragment.independent() for fragment in fragments), 0, ) + self.visible_categories = all_categories() + self._fragment_views = () + self.prepared.configure(batch_size=batch_size, newest=newest) + super().__init__() + + @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.start + + @start.setter + def start(self, start: int) -> None: + self.prepared.start = start + + @property + def stop(self) -> int: + return self.prepared.stop - def _body(self, fragment: TranscriptFragment) -> TranscriptFragmentView: - return TranscriptFragmentView(fragment, self.visible_categories) + @stop.setter + def stop(self, stop: int) -> None: + 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 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 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, 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 = 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 @@ -277,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 @@ -319,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 @@ -337,14 +423,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, + max_items=self.MAX_FRAGMENTS, admission_items=PreparedContentRange.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 +440,51 @@ 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.""" + 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) + await source.runtime.run_thread(source.measure_admission, pages, projected) + 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 +497,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) @@ -472,11 +606,13 @@ 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 + if (projection := self.filter.state.overlay) is not None: + yield projection for page in self.pages: page.visible_categories = self.selected_categories yield page @@ -586,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: @@ -611,7 +747,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,11 +852,11 @@ 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 - selected = TranscriptPageView.initial_slice(fragments, destination_admission, True) + page, fragments = prepared.page, prepared.fragments + selected = PreparedContentRange.initial_slice(fragments, destination_admission, True) await self.prepare_fragments( fragments[selected], lambda: snapshot.current(self) and request.current(window), ) @@ -748,6 +883,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 +902,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 @@ -774,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), ) @@ -793,7 +930,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 +942,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. @@ -814,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) @@ -822,6 +960,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: @@ -881,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() @@ -897,19 +1036,33 @@ 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: + page = self.pages[0] + page.prepared.select_admission(slice(page.start, page.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 @@ -930,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 22609c5ac..a92efdbca 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 @@ -22,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 @@ -30,6 +32,10 @@ from textual.worker import Worker from textual.await_complete import AwaitComplete +if TYPE_CHECKING: + from textual.document._paint import DocumentPaint + from toad.markdown_preparation import PreparedMarkdown + class ViewportBody: """Nominal contract implemented by source-owning document widgets.""" @@ -138,6 +144,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: ... @@ -147,6 +157,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) @@ -215,6 +230,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 @@ -235,7 +254,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.""" @@ -251,6 +270,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 @@ -278,6 +309,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,8 +324,28 @@ 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 + source_bytes: int = 0 + + def source_cost(self, body): + return self.source_bytes + + def source_empty(self, body, native_empty): + return self.root_empty + + def invalidated(self): + return 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) @@ -336,6 +391,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. @@ -416,6 +515,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. @@ -435,6 +537,22 @@ 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 + def height(self, body, width, measure): return self.previous.height(body, width, measure) @@ -442,7 +560,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) @@ -462,7 +580,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)) @@ -470,6 +588,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() @@ -516,7 +640,9 @@ 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. - return LiveBody(self.width, self.rows, self.widgets) + if isinstance(result, BodyMeasurement): + return result + return body.live_body_measurement(self.width, self.rows, self.widgets) return self.previous return self._updated(self.previous.publication_finished(body, worker, result)) @@ -529,12 +655,16 @@ 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, source_bytes=self.source_bytes) + @property def dormant(self): return True @@ -568,7 +698,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 +711,121 @@ 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): + body.invalidate_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 + source_resource: "PreparedMarkdown" + + def source_cost(self, body): + return self.source_resource.retained_bytes + + @property + def document_paint(self): + return self.paint + + @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. + # 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") + 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) + 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, root_selection=self.paint.root_selection, + selection_style=self.paint.selection_style, selecting=self.paint.selecting, + )) + return self.invalidated() + + def invalidated(self, width=None): + return PendingDocumentBody(self.paint, self.resource_bytes, self.source_resource, + 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, + source_bytes=self.source_cost(None)) async def restore(self, body): if not self.ready(body): @@ -589,6 +833,32 @@ async def restore(self, 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.""" @@ -599,10 +869,28 @@ 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 + @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) @@ -660,13 +948,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()) @@ -674,13 +966,27 @@ 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. 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() @@ -688,6 +994,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)) @@ -708,19 +1020,25 @@ 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() + for child in self.child_bodies(): + 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 +1115,8 @@ 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 + source_bytes = current.source_cost(self) captured = tuple(compositor.published_geometry((self,))) if not captured: return unprepared.prepare_publication(self) @@ -819,6 +1139,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, source_bytes=source_bytes, ) return measured() @@ -838,7 +1159,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 @@ -885,19 +1209,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 @@ -971,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()), @@ -1137,7 +1468,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 @@ -1313,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 @@ -1342,7 +1673,15 @@ 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, + *self.window.reader_bodies, + *(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. @@ -1368,7 +1707,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: @@ -1383,7 +1722,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. @@ -1397,7 +1736,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 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) 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("