From 04d2cb348b8c46d01a75dd921de440e02815f103 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 12:20:28 -0400 Subject: [PATCH 1/5] Acquire UI owners only when CLI commands execute Keep Click declarations and renderer-choice discovery available to console-entrypoint spawn imports. Move ToadApp into run/acp terminal branches and about, ApplicationLifetime into the three server branches, and annotation-only App/AgentDefinition/Renderer imports behind TYPE_CHECKING. No wrapper entrypoint or alternate pool. NRA AST traced all CLI declarations/callers and renderer imports across 862 Toad/Core/native production files with zero parse omissions. CLI declaration set, classes, AST signatures, returns and decorators are unchanged. Native help/version output is byte-identical; actual ACP project-option precedence and renderer choices pass. Actual existing RenderProcessPool spawn borrowed the unchanged installed console script as its original main path, delivered RichRenderTask through PreparationRuntime, and confirmed the worker imported toad.cli without toad.app, application_lifetime or conversation. Worker shutdown left no child. One worker, installed dependencies, no App/provider/build launch. --- src/toad/cli.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/toad/cli.py b/src/toad/cli.py index c1ff930d6..b4d834d8c 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -1,13 +1,17 @@ +from __future__ import annotations + import os import sys +from typing import TYPE_CHECKING import click -from toad.app import ToadApp -from toad.application_lifetime import ApplicationLifetime -from toad.agent_schema import AgentDefinition -from toad.render_backend import Renderer from toad.render_choices import RendererChoice +if TYPE_CHECKING: + from toad.app import ToadApp + from toad.agent_schema import AgentDefinition + from toad.render_backend import Renderer + def persist_terminal_failure(error: Exception) -> None: """Keep Textual's terminal failure in the existing diagnostic directory.""" @@ -181,6 +185,7 @@ def run( if serve: import shlex + from toad.application_lifetime import ApplicationLifetime from toad.web_server import ToadWebServer command_args = list(sys.argv) @@ -202,6 +207,8 @@ def run( ApplicationLifetime.set_process_title("toad --serve") server.serve() else: + from toad.app import ToadApp + app = ToadApp( mode=None if agent_data else "store", agent_data=agent_data, @@ -286,6 +293,7 @@ def acp( if serve: import shlex + from toad.application_lifetime import ApplicationLifetime from toad.web_server import ToadWebServer command_components = [sys.argv[0], "acp", command] @@ -307,6 +315,8 @@ def acp( server.serve() else: + from toad.app import ToadApp + app = ToadApp( agent_data=agent_data, project_dir=project_dir, agent_session_id=session_id, renderer=renderer_from_cli(renderer), @@ -363,6 +373,7 @@ def replay(path: str) -> None: ) def serve(port: int, host: str, public_url: str | None = None) -> None: """Serve Toad as a web application.""" + from toad.application_lifetime import ApplicationLifetime from toad.web_server import ToadWebServer server = ToadWebServer( @@ -377,6 +388,7 @@ def about() -> None: """Show about information.""" from toad import about + from toad.app import ToadApp app = ToadApp() From 70080a10ffb4c5925b86eb8c8c1d22f9141c4bfc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 12:20:28 -0400 Subject: [PATCH 2/5] Acquire UI owners only when CLI commands execute Keep Click declarations and renderer-choice discovery available to console-entrypoint spawn imports. Move ToadApp into run/acp terminal branches and about, ApplicationLifetime into the three server branches, and annotation-only App/AgentDefinition/Renderer imports behind TYPE_CHECKING. No wrapper entrypoint or alternate pool. NRA AST traced all CLI declarations/callers and renderer imports across 862 Toad/Core/native production files with zero parse omissions. CLI declaration set, classes, AST signatures, returns and decorators are unchanged. Native help/version output is byte-identical; actual ACP project-option precedence and renderer choices pass. Actual existing RenderProcessPool spawn borrowed the unchanged installed console script as its original main path, delivered RichRenderTask through PreparationRuntime, and confirmed the worker imported toad.cli without toad.app, application_lifetime or conversation. Worker shutdown left no child. One worker, installed dependencies, no App/provider/build launch. --- src/toad/cli.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/toad/cli.py b/src/toad/cli.py index c1ff930d6..b4d834d8c 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -1,13 +1,17 @@ +from __future__ import annotations + import os import sys +from typing import TYPE_CHECKING import click -from toad.app import ToadApp -from toad.application_lifetime import ApplicationLifetime -from toad.agent_schema import AgentDefinition -from toad.render_backend import Renderer from toad.render_choices import RendererChoice +if TYPE_CHECKING: + from toad.app import ToadApp + from toad.agent_schema import AgentDefinition + from toad.render_backend import Renderer + def persist_terminal_failure(error: Exception) -> None: """Keep Textual's terminal failure in the existing diagnostic directory.""" @@ -181,6 +185,7 @@ def run( if serve: import shlex + from toad.application_lifetime import ApplicationLifetime from toad.web_server import ToadWebServer command_args = list(sys.argv) @@ -202,6 +207,8 @@ def run( ApplicationLifetime.set_process_title("toad --serve") server.serve() else: + from toad.app import ToadApp + app = ToadApp( mode=None if agent_data else "store", agent_data=agent_data, @@ -286,6 +293,7 @@ def acp( if serve: import shlex + from toad.application_lifetime import ApplicationLifetime from toad.web_server import ToadWebServer command_components = [sys.argv[0], "acp", command] @@ -307,6 +315,8 @@ def acp( server.serve() else: + from toad.app import ToadApp + app = ToadApp( agent_data=agent_data, project_dir=project_dir, agent_session_id=session_id, renderer=renderer_from_cli(renderer), @@ -363,6 +373,7 @@ def replay(path: str) -> None: ) def serve(port: int, host: str, public_url: str | None = None) -> None: """Serve Toad as a web application.""" + from toad.application_lifetime import ApplicationLifetime from toad.web_server import ToadWebServer server = ToadWebServer( @@ -377,6 +388,7 @@ def about() -> None: """Show about information.""" from toad import about + from toad.app import ToadApp app = ToadApp() From 80d9a385122b6a3bc21b96c4c354e4ad1894881e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 14:06:17 -0400 Subject: [PATCH 3/5] Respect transcript categories before preparation and bound native mounts Snapshot and checkpoint publication pass the actual view categories into unattached history preparation. TranscriptBodyPreparation skips filtered source fragments; TranscriptFragmentView retains their source slots without hidden native children and uses its existing body publisher on reveal. PreparedContentRange owns extension and replacement construction in four-part mount bursts with existing admission guards and rollback. TranscriptPageView acquires large destination slices through that owner. Preserve StreamingMarkdown prefix ordering in its extension call. Keep original four-fragment page-load admission after larger cohorts regressed. Source trace covered 862 Toad/Core/native modules with no AST parse omissions. Qualified the real 147MB saved source through the original observer journey: 320 native wheel packets, three reversals, no provider input or application error; max delivery 219.7ms versus retained baseline 393.5ms. Original canonical page read 0.105s. Real category hide/reveal preserved canonical page and collapsed unformatted disclosure identity. Qualification uses a source overlay on installed dependencies, not an installed delivery. Five-second agent-tab startup remains unresolved; original ACP attachment and metadata precede bounded snapshot publication. No viewport_body or native compositor changes. --- src/toad/transcript_publication.py | 5 +- src/toad/widgets/prepared_markdown.py | 63 ++++++++++++++---------- src/toad/widgets/streaming_markdown.py | 2 +- src/toad/widgets/transcript_fragments.py | 6 ++- src/toad/widgets/transcript_history.py | 23 +++++++-- 5 files changed, 64 insertions(+), 35 deletions(-) diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index e43caa4e7..7a8ef63ac 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -196,7 +196,8 @@ async def publish_page(self) -> None: history = TranscriptHistory(self.page, self.agent.get_transcript_page, fragments=fragments, committed=False) self.owner.prepare_reader(history) - await history.prepare_body(partial(self.source_current, self.page.after)) + await history.prepare_body(partial(self.source_current, self.page.after), + selected=view.visible_categories) async with AsyncExitStack() as retirement: async with self.window.history_lock: # Preparation may yield to another accepted source publication. @@ -447,7 +448,7 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: replacement = TranscriptHistory( page, self.agent.get_transcript_page, fragments=prepared.fragments, committed=False, ) - await replacement.prepare_body(is_current) + await replacement.prepare_body(is_current, selected=view.visible_categories) async with AsyncExitStack() as retirement: async with window.history_lock: retired = retirement_candidates(contents.children, evidence) diff --git a/src/toad/widgets/prepared_markdown.py b/src/toad/widgets/prepared_markdown.py index 987b40497..edee6580c 100644 --- a/src/toad/widgets/prepared_markdown.py +++ b/src/toad/widgets/prepared_markdown.py @@ -86,40 +86,51 @@ def update_slice(self, fragments, follow: bool) -> slice: stop = min(self.stop, len(fragments)) return slice(min(self.start, stop), stop) - async def extend(self, older: bool, current: Callable[[], bool]) -> bool: + async def extend(self, older: bool, current: Callable[[], bool], *, prefix=()) -> bool: admission = self.capture_admission() - selected = self.extension_slice(older) - added = tuple(self._body(fragment) for fragment in self.fragments[selected]) - previous = self.fragment_views - with ExitStack() as acquisition: - if added: - acquisition.callback(self.remove_children, added) - await self.mount_all(added, before=previous[0] if older and previous else None) - if not current() or self.capture_admission() != admission: - return False - self._fragment_views = (*added, *previous) if older else (*previous, *added) - if older: - self.start = selected.start - else: - self.stop = selected.stop - acquisition.pop_all() - return True + extension = self.extension_slice(older) + selected = slice(extension.start if older else self.start, + self.stop if older else extension.stop) + previous = {self.start + index: child for index, child in enumerate(self.fragment_views)} + return await self.replace_range( + self.fragments, selected, previous, + lambda: current() and self.capture_admission() == admission, + prefix=prefix, + ) async def replace_range(self, fragments, selected, previous, current, *, prefix=()) -> bool: """Publish ordered native parts inside their owner's source custody.""" - ordered = tuple(previous[index] if index in previous else self._body(fragments[index]) - for index in range(selected.start, selected.stop)) - added = tuple(child for child in ordered if child not in previous.values()) + ordered = [] + added = [] with ExitStack() as acquisition: - if added: - acquisition.callback(self.remove_children, added) - await self.mount_all(added) + 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, ordered + self.fragments, self._fragment_views = fragments, tuple(ordered) self.start, self.stop = selected.start, selected.stop - rank = {child: index for index, child in enumerate((*prefix, *ordered))} - self.sort_children(key=lambda child: rank.get(child, len(rank))) + 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 diff --git a/src/toad/widgets/streaming_markdown.py b/src/toad/widgets/streaming_markdown.py index cf48d2e21..6f518cbbe 100644 --- a/src/toad/widgets/streaming_markdown.py +++ b/src/toad/widgets/streaming_markdown.py @@ -228,7 +228,7 @@ def current(): self.fragments, selected, previous, current, prefix=self._prefix, ): return False - elif not await self.extend(older, current): + elif not await self.extend(older, current, prefix=self._prefix): return False visible = self.screen._compositor.visible_widgets protected = protected_presentations(self.fragment_views, self.screen._interaction_widgets()) diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 20170a7f7..a5b9d975b 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -16,7 +16,7 @@ from toad.render_backend import ReusableRenderTask from toad.acp.status import ToolCallStatus from toad.widgets.agent_activity import AgentActivityBoundary -from toad.widgets.message_filter import event_category +from toad.widgets.message_filter import event_category, keep_events if TYPE_CHECKING: from toad.render_backend import Renderer @@ -246,8 +246,9 @@ def transcript_fragments( class TranscriptBodyPreparation(MroDispatch): """Pure body work for declared transcript cases, without native widgets.""" - def __init__(self, renderer, ansi: bool, dark: bool): + def __init__(self, renderer, ansi: bool, dark: bool, *, selected=None): self.renderer, self.ansi, self.dark = renderer, ansi, dark + self.selected = selected async def prepare_fragments(self, fragments, keep_going, *, batch_size: int) -> None: """Warm a bounded source range in shared workers, without native mounts. @@ -260,6 +261,7 @@ async def prepare_fragments(self, fragments, keep_going, *, batch_size: int) -> return await asyncio.gather(*(self.dispatch(event) for fragment in fragments[first:first + batch_size] + if self.selected is None or keep_events(fragment.events, self.selected) for event in fragment.events)) @handles(TranscriptEvent) diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 0ad2c290f..64b443f8a 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -165,10 +165,17 @@ def message_category(self) -> type[MessageCategory]: def set_categories(self, selected: frozenset[type[MessageCategory]]) -> None: if getattr(self, "selected_categories", None) == selected: return + visible = self.message_category in getattr(self, "selected_categories", ()) self.selected_categories = selected apply_block_filter(self, selected) + if self.is_attached and not visible and self.message_category in selected and not self.children: + self.publish_body(self.materialize_native_body).call_next(self) def compose(self) -> ComposeResult: + # A filtered source slot retains identity/coverage, not hidden native + # bodies. Its existing body publisher materializes on first reveal. + if self.message_category not in self.selected_categories: + return # A semantic fragment may be one oversized paragraph, list, or fence. # It is already a page leaf: re-paging it would recursively remount the # same indivisible block forever without producing visible Markdown. @@ -241,13 +248,19 @@ async def acquire( batch_size: int, before: Widget, current: Callable[[], bool], newest: bool = True, ) -> AsyncIterator[TranscriptPageView]: """Acquire a page until its original source transfers native custody.""" - view = cls(page, fragments=fragments, batch_size=batch_size, newest=newest) + view = cls(page, fragments=fragments, batch_size=min(batch_size, cls.BATCH), newest=newest) 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) + 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): + raise _PublicationRetired + view.batch_size = batch_size yield view acquisition.pop_all() @@ -332,19 +345,20 @@ def __init__(self, page: TranscriptPage, loader: Callable[..., Awaitable[Transcr self._fragment_budget = self.budget.max_items self.window: HistoryWindow - async def prepare_fragments(self, fragments, current: Callable[[], bool]) -> None: + 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 preparation = TranscriptBodyPreparation( self.app.render_processes, self.app.native_ansi_color, self.app.current_theme.dark, + selected=self.selected_categories if selected is None else selected, ) await preparation.prepare_fragments(fragments, current, batch_size=self.budget.admission_items) - async def prepare_body(self, current: Callable[[], bool]) -> None: + async def prepare_body(self, current: Callable[[], bool], *, selected=None) -> None: """Warm the actual page admissions, including a restored reader range.""" for page in self.pages: - await self.prepare_fragments(page.fragments[page.start:page.stop], current) + await self.prepare_fragments(page.fragments[page.start:page.stop], current, selected=selected) @property def fragment_views(self) -> tuple[TranscriptFragmentView, ...]: @@ -628,6 +642,7 @@ async def prepare_lookahead(self, intent) -> None: from toad.widgets.transcript_fragments import TranscriptBodyPreparation preparation = TranscriptBodyPreparation( self.app.render_processes, self.app.native_ansi_color, self.app.current_theme.dark, + selected=self.selected_categories, ) for page in pages: await page.prepare_adjacent(preparation, demand, count, current) From cd89701ae3c98ad4e4e797b00b9345a8ca815ef1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 14:41:26 -0400 Subject: [PATCH 4/5] Open current model thinking choices without reapplying model defaults --- src/toad/widgets/conversation.py | 8 ++++++-- tests/model_picker_pilot.py | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 0aeb9419f..8ae3fa588 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1171,7 +1171,11 @@ async def action_provider_login(self) -> None: async def on_change_model(self, event: CoreEventMessage) -> None: if (agent := self.agent) is None: return - error = await agent.set_model(event.event.model_id) + # Selecting the current model opens its thinking choices. Reapplying it + # would ask native SetModel to restore that model's default effort. + error = None + if event.event.model_id != agent.configuration.model.current: + error = await agent.set_model(event.event.model_id) if agent is not self.agent: return if error is not None: @@ -1209,7 +1213,7 @@ async def on_change_model(self, event: CoreEventMessage) -> None: return self.flash( Content.from_markup( - "Model changed to [b]$model[/] · thinking [b]$level", + "Model [b]$model[/] · thinking [b]$level", model=model.name, level=agent.configuration.thinking.current or "unavailable", ), diff --git a/tests/model_picker_pilot.py b/tests/model_picker_pilot.py index 7292634d1..6a272c82e 100644 --- a/tests/model_picker_pilot.py +++ b/tests/model_picker_pilot.py @@ -150,9 +150,10 @@ def ids(): await pilot.press(*"glm") await pilot.pause() assert await pilot.click(picker.option_list, offset=(2, 0)) - await until(lambda: agent.calls and agent.calls[-1] == glm) + await until(lambda: not picker.is_open) await pilot.pause() assert not picker.is_open + assert not agent.calls, "Opening the current model must not reapply it" await CommandCatalog.execute("/model", conversation) await pilot.pause() From c22db896f269e24a6b2d521b704f99403f23948f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 14:59:30 -0400 Subject: [PATCH 5/5] Price retained message paint by its bytes rather than native item count --- src/toad/widgets/presentation_window.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/toad/widgets/presentation_window.py b/src/toad/widgets/presentation_window.py index 66c085e81..ed9e6091e 100644 --- a/src/toad/widgets/presentation_window.py +++ b/src/toad/widgets/presentation_window.py @@ -87,16 +87,16 @@ def admit_paint(self, candidates, required, max_bytes: int): """Retain message paint independently of its disposable native tree. The native widget limit still governs lookahead materialization. Paint - admission bounds original message resources and their source/row bytes; - a live tree awaiting capture must not price its eventual retained rows. + admission bounds original message resources by their source/row bytes. + Native item counts price source slots and preparation, not immutable + paint; a live tree awaiting capture must not price its eventual rows. """ admitted = set(required) source_bytes = 0 - limit = self.item_limit(len(admitted)) for owner in candidates: size = owner.retained_source_bytes + owner.retained_paint_bytes if owner not in admitted: - if len(admitted) >= limit or source_bytes + size > max_bytes: + if source_bytes + size > max_bytes: continue admitted.add(owner) source_bytes += size