From fd576f03495c11a37ce1f64fee083e5cb3037a29 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 04:08:02 -0400 Subject: [PATCH 01/43] P401: discover session owners through a screen-free scope --- src/toad/project_path_owner.py | 7 +- src/toad/screens/main.py | 3 +- src/toad/screens/session_view.py | 34 +------- src/toad/screens/welcome.py | 3 +- src/toad/session_entry.py | 4 +- src/toad/session_scope.py | 109 +++++++++++++++++++++++++ src/toad/thread_navigation.py | 2 +- src/toad/transcript_publication.py | 7 +- src/toad/widgets/channel_activity.py | 4 +- src/toad/widgets/comms_chat.py | 10 +-- src/toad/widgets/context_explorer.py | 6 +- src/toad/widgets/conversation.py | 40 +++------ src/toad/widgets/thread_comms.py | 4 +- src/toad/widgets/thread_identity.py | 7 +- src/toad/widgets/transcript_history.py | 4 +- src/toad/widgets/wire_history.py | 4 +- src/toad/wire_history.py | 4 +- src/toad/workspace_chrome.py | 4 +- src/toad/workspace_sessions.py | 30 ++++--- tests/app/test_session_scope.py | 25 ++++++ 20 files changed, 199 insertions(+), 112 deletions(-) create mode 100644 src/toad/session_scope.py create mode 100644 tests/app/test_session_scope.py diff --git a/src/toad/project_path_owner.py b/src/toad/project_path_owner.py index 49cca3669..43be855e7 100644 --- a/src/toad/project_path_owner.py +++ b/src/toad/project_path_owner.py @@ -1,8 +1,13 @@ """Original filesystem context shared by live and archived rich history.""" +from __future__ import annotations + from abc import abstractmethod from pathlib import Path -from textual.widget import Widget +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from textual.widget import Widget class ProjectPathOwner: diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index c83d77df3..458fb0353 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -37,7 +37,8 @@ from toad.agent_schema import AgentDefinition from toad.app import ToadApp from toad.navigation_target import NavigationContext, NavigationOwner -from toad.screens.session_view import SessionOrigin, SessionView +from toad.screens.session_view import SessionView +from toad.session_scope import SessionOrigin from toad.widgets.message_notifications import NothingToRead, PaintedRead, ReadWork from toad.screens.mcp_inventory import MCPInventoryScreen from toad.session_presentation import OperationalSessionPresentation diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 713f106e1..780b59ca0 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -7,42 +7,12 @@ from agent_comms.mro_dispatch import handles from toad.core.events import ThreadIdentityChanged from toad.core.source_events import TranscriptSourceWorkFinished -from typing import TYPE_CHECKING from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.thread_identity import SessionIdentity, NoThreadIdentity -from toad.project_path_owner import ProjectPathOwner +from toad.session_scope import SessionOrigin, SessionScope from toad.widgets.message_notifications import NothingToRead, PaintedRead, VisibleReadMarking -if TYPE_CHECKING: - from toad.agent_schema import AgentDefinition - - -class SessionOrigin: - """A view sessions and histories are opened from: it names their agent, project and bus sender.""" - - _agent: "AgentDefinition | None" - - @property - def origin(self) -> "SessionOrigin": - return self - - def transcript_scope(self) -> tuple[str, str] | None: - """The canonical history source, independent of an ACP connection.""" - return self.thread_identity.transcript_scope() - - def resolve_thread(self) -> str: - """Resolve a navigation name without rebinding this displayed source.""" - from agent_comms.route_selection import RouteSelection - - identity = self.thread_identity - selected = RouteSelection.capture(identity.wire_root()) - service = self.app.coordination_access.require(selected).service - return identity.resolve_thread(service.registry, self.project_path) - - def has_agent(self) -> bool: - return self._agent is not None - -class SessionView(VisibleReadMarking, CoreEventReceiver, ProjectPathOwner, Vertical): +class SessionView(VisibleReadMarking, CoreEventReceiver, Vertical, SessionScope): title = reactive("") sub_title = reactive("") diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index e0dc0d68c..6052e2a85 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -22,7 +22,8 @@ from toad.format_path import format_path from toad.navigation_target import FeedTarget, NavigationContext, NavigationOwner, NavigationTarget from toad.navigation_target import channel_target, ThreadTarget -from toad.screens.session_view import SessionOrigin, SessionView +from toad.screens.session_view import SessionView +from toad.session_scope import SessionOrigin from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction from toad.widgets.sidebar_tree import RowReconciliation, SidebarChange, SidebarGroup, SidebarModelView, TargetTree diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index ede2e3bcf..9dc2ee44b 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -13,6 +13,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.route_selection import RouteSelection from toad.owner_preparation import ExistingOwnerPlan +from toad.session_scope import SessionScope from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.sidebar import ThreadRowModel @@ -23,7 +24,6 @@ if TYPE_CHECKING: from toad.session_navigation import SessionNavigation from toad.screens.main import MainScreen - from toad.screens.session_view import SessionView from toad.comms_root import Observing @@ -36,7 +36,7 @@ def owner_mode(self) -> str: return self.mode @abstractmethod - def __call__(self) -> SessionView: ... + def __call__(self) -> SessionScope: ... def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: return self.unobserved_tab() diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py new file mode 100644 index 000000000..5216a78bb --- /dev/null +++ b/src/toad/session_scope.py @@ -0,0 +1,109 @@ +"""The session contract consumed by descendants and workspace owners. + +Like ProjectPathOwner, this abstract capability has no ABC metaclass: Textual +owns its widgets' metaclass. Native widget methods and data descriptors remain +implemented by the view's existing parents, not by a forwarding wrapper. +""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Awaitable +from pathlib import Path +from typing import TYPE_CHECKING + +from agent_comms.route_selection import RouteSelection +from toad.project_path_owner import ProjectPathOwner + +if TYPE_CHECKING: + from textual.command import Provider + from textual.dom import DOMNode + from toad.agent_schema import AgentDefinition + from toad.navigation_target import NavigationContext + from toad.session_presentation import OperationalSessionPresentation + from toad.thread_identity import SessionIdentity + + +class SessionOrigin: + """The existing agent/project origin of sessions and histories.""" + + _agent: AgentDefinition | None + + @property + def origin(self) -> SessionOrigin: + return self + + def transcript_scope(self) -> tuple[str, str] | None: + return self.thread_identity.transcript_scope() + + def resolve_thread(self) -> str: + identity = self.thread_identity + selected = RouteSelection.capture(identity.wire_root()) + service = self.app.coordination_access.require(selected).service + return identity.resolve_thread(service.registry, self.project_path) + + def has_agent(self) -> bool: + return self._agent is not None + + +class SessionScope(ProjectPathOwner): + """What a mounted logical session answers, independently of its screen.""" + + # Existing native data descriptors and concrete-view state. An abstract + # property here would shadow their inherited setters or instance attributes. + id: str | None + display: bool + screen: DOMNode + COMMANDS: set[type[Provider]] + AUTO_FOCUS: str + shows_channels: bool + footer_compact: bool + presentation: OperationalSessionPresentation + navigation_context: NavigationContext + + @property + @abstractmethod + def is_current(self) -> bool: ... + + @property + @abstractmethod + def display_name(self) -> str: ... + + @property + @abstractmethod + def origin(self) -> SessionOrigin | None: ... + + @property + @abstractmethod + def thread_identity(self) -> SessionIdentity: ... + + @abstractmethod + def belongs_to_wire(self, root: str | Path) -> bool: ... + + @abstractmethod + def sidebars(self) -> tuple[DOMNode, ...]: ... + + @abstractmethod + def sidebar_focus_target(self) -> DOMNode | None: ... + + @abstractmethod + async def prepare_presentation(self) -> None: ... + + @abstractmethod + async def retire_presentation(self) -> None: ... + + @abstractmethod + async def close_presentation(self) -> None: ... + + @abstractmethod + def activate_session(self) -> None: ... + + @abstractmethod + def close_core_events(self) -> None: ... + + @abstractmethod + def query_one_optional[Node]( + self, selector: str | type[Node], expect_type: type[Node] | None = None, + ) -> Node | None: ... + + @abstractmethod + def remove(self) -> Awaitable[None]: ... diff --git a/src/toad/thread_navigation.py b/src/toad/thread_navigation.py index dcf771d93..246e4db54 100644 --- a/src/toad/thread_navigation.py +++ b/src/toad/thread_navigation.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from toad.app import ToadApp from toad.screens.main import MainScreen - from toad.screens.session_view import SessionOrigin + from toad.session_scope import SessionOrigin from toad.navigation_preparation import ThreadNavigation diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 5d9da35c4..a418e6355 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -27,10 +27,9 @@ from toad.widgets.session_details import SessionDetails from toad.transcript_preparation import PreparedPageSource from toad.core.events import CoreEventStream -from toad.screens.session_view import SessionView +from toad.session_scope import SessionOrigin, SessionScope if TYPE_CHECKING: - from toad.screens.session_view import SessionOrigin from toad.widgets.conversation import Conversation @@ -444,12 +443,12 @@ class NoView(ViewBinding): class BoundView(ViewBinding): @dataclass(frozen=True) class Moment(BoundFrame): - source: SessionView + source: SessionScope @classmethod def capture(cls, binding, owner, **extra): return super().capture(binding, owner, - source=binding.view.query_ancestor(SessionView), **extra) + source=binding.view.query_ancestor(SessionScope), **extra) def current(self): return (super().current() and self.source.is_current diff --git a/src/toad/widgets/channel_activity.py b/src/toad/widgets/channel_activity.py index a3f12d750..a99af04e2 100644 --- a/src/toad/widgets/channel_activity.py +++ b/src/toad/widgets/channel_activity.py @@ -4,7 +4,7 @@ from textual.content import Content from textual.containers import VerticalGroup -from toad.screens.session_view import SessionView +from toad.session_scope import SessionScope from toad.widgets.observed_thread_activity import StatusThread from toad.widgets.sidebar_tree import KeyedRows, SidebarChange, SidebarModelView @@ -39,7 +39,7 @@ def member_keys(self): return tuple(row.name for row in self.members) def accepts_observation(self) -> bool: - return (self.view.query_ancestor(SessionView).is_current + return (self.view.query_ancestor(SessionScope).is_current and self.model_state.serves(self.view._bound_root)) def model_replaced(self) -> None: diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 6a5036e17..25c5d3bb5 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -36,7 +36,7 @@ from toad.widgets.observed_thread_activity import StatusThread from toad.widgets.flash import Flash from toad.widgets.prompt import Prompt -from toad.screens.session_view import SessionView +from toad.session_scope import SessionScope from toad.command_catalog import CommandCatalog @@ -191,9 +191,7 @@ def prepare_prompt(self) -> None: prompt.sync_session() prompt.shell_mode = False prompt.update_prompt() - from toad.screens.session_view import SessionView - - if self.query_ancestor(SessionView).is_current: + if self.query_ancestor(SessionScope).is_current: prompt.focus() def watch_agent_binding(self, agent) -> None: @@ -210,7 +208,7 @@ def status_thread(self) -> StatusThread: async def _refresh(self) -> Worker | None: history = self.message_history - if not self.is_attached or not self.query_ancestor(SessionView).is_current: + if not self.is_attached or not self.query_ancestor(SessionScope).is_current: return return history.page_source.schedule_refresh(self) @@ -272,7 +270,7 @@ async def _refresh_labels(self, source, changed: bool, newly_announced: bool, re if not await history.source_is_current(snapshot): return if (not self.is_attached or source is not history.page_source - or not self.query_ancestor(SessionView).is_current): + or not self.query_ancestor(SessionScope).is_current): return self.call_after_refresh(history.mark_visible) if not changed: diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 499646dd4..130b95bb8 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -23,7 +23,7 @@ from agent_comms.route_selection import RouteSelection from toad.thread_identity import SessionIdentity, ThreadIdentity from toad.widgets.thread_identity import ShowsThreadIdentity -from toad.screens.session_view import SessionView +from toad.session_scope import SessionScope from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarVisibilityObserver @@ -261,7 +261,7 @@ def context_changed(self, message: CoreEventMessage): self._observed() def presentation_visible(self): - return (self.is_attached and self.query_ancestor(SessionView).is_current + return (self.is_attached and self.query_ancestor(SessionScope).is_current and not self.query_ancestor(SideBar).collapsed and not self.query_ancestor(SideBarCollapsible).collapsed) @@ -396,7 +396,7 @@ def action_correct(self, event): async def _correct_annotation(self, model, answer): if not self._selected(model): return - worktree = self.query_ancestor(SessionView).project_root + worktree = self.query_ancestor(SessionScope).project_root await model.correct(answer, worktree) if self._selected(model): self.notify("Original human correction recorded") diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 116bb82e9..6d099fa59 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -2,6 +2,7 @@ from toad.core import input_events from toad.core_event_carrier import CoreEventMessage from toad.widget_view import WorkspaceHosted +from toad.session_scope import SessionScope from toad.conversation_submission import ConversationSubmissions from toad.live_output import LiveOutput, ThoughtStream @@ -351,15 +352,11 @@ class ConversationSessionBinding(WorkspaceHosted, ThreadStatusOwner, containers. @property def operational_sources(self): - from toad.screens.session_view import SessionView - - return self.query_ancestor(SessionView).presentation.sources + return self.query_ancestor(SessionScope).presentation.sources def transcript_scope(self) -> tuple[str, str] | None: """The native session identity whose canonical transcript this view shows.""" - from toad.screens.session_view import SessionView - - origin = self.query_ancestor(SessionView).origin + origin = self.query_ancestor(SessionScope).origin return origin.transcript_scope() if origin is not None else None def status_thread(self) -> StatusThread: @@ -385,13 +382,11 @@ def foreign_thread_read(self, presentation) -> None: def thread_presented(self, presentation) -> None: """A settled presentation refreshes the transcript source of the shown view.""" - from toad.screens.session_view import SessionView - self.thread_presentation = presentation self._update_model_info() if presentation is None or not self.transcript.readable: return - if self.query_ancestor(SessionView).is_current: + if self.query_ancestor(SessionScope).is_current: from toad.transcript_publication import ObservedSourcePublication self._unpublished_presentation = None self.transcript.source_requests.request(ObservedSourcePublication, presentation) @@ -400,9 +395,7 @@ def thread_presented(self, presentation) -> None: @handles(core_events.SessionSelected) async def _thread_view_selected(self, event: CoreEventMessage) -> None: - from toad.screens.session_view import SessionView - - if not self.query_ancestor(SessionView).is_current: + if not self.query_ancestor(SessionScope).is_current: return self.refresh_thread_status() self.goal_observation.invalidate() @@ -804,9 +797,7 @@ def remember_tool_expansion(self, tool_id: str, expanded: bool) -> None: @property def agent_title(self) -> str: - from toad.screens.session_view import SessionView - - return self.query_ancestor(SessionView).display_name + return self.query_ancestor(SessionScope).display_name def insert_path_into_prompt(self, path: Path) -> None: if not path.is_relative_to(self.project_path): @@ -1263,11 +1254,9 @@ 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() cursor = self.navigation.cursor - if (self.query_ancestor(SessionView).is_current and event.action is not None + if (self.query_ancestor(SessionScope).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: @@ -1475,11 +1464,9 @@ async def update_slash_commands(self) -> None: Hidden sources keep their display resource and refresh on selection; command execution always reacquires current backend applicability. """ - from toad.screens.session_view import SessionView - if not self.is_attached: return - source = self.query_ancestor(SessionView) + source = self.query_ancestor(SessionScope) if not source.is_current: return catalog = await self.read_command_catalog() @@ -1487,8 +1474,7 @@ async def update_slash_commands(self) -> None: self.prompt.slash_commands = catalog.commands async def read_command_catalog(self) -> CommandCatalog: - from toad.screens.session_view import SessionView - source = self.query_ancestor(SessionView) + source = self.query_ancestor(SessionScope) navigation = source.navigation_context agent = self.agent_binding context = self.command_target_context() @@ -1618,10 +1604,8 @@ def on_mouse_down(self, event: events.MouseDown) -> None: self._mouse_down_offset = event.screen_offset def on_click(self, event: events.Click) -> None: - from toad.screens.session_view import SessionView - # A queued history click may arrive after its logical source retired. - if not self.query_ancestor(SessionView).is_current: + if not self.query_ancestor(SessionScope).is_current: return if ( self._mouse_down_offset is not None @@ -1940,12 +1924,10 @@ def refresh_block_cursor(self, show) -> None: self.refresh_bindings() def _present_block_cursor(self, cursor, show) -> None: - from toad.screens.session_view import SessionView - # A later prompt, tab or source event may already have moved the cursor # off this block. if (self.is_attached and not self.is_leaving - and self.query_ancestor(SessionView).is_current + and self.query_ancestor(SessionScope).is_current and self.navigation.cursor is cursor): show(cursor) self.cursor.refresh() diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 11eea4c92..3ddd636c0 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -3,6 +3,7 @@ from __future__ import annotations from agent_comms.mro_dispatch import handles from toad.core_event_carrier import CoreEventMessage +from toad.session_scope import SessionScope from toad.thread_identity import ThreadIdentity, SessionIdentity from toad.widgets.thread_identity import ShowsThreadIdentity from toad.core import events as core_events @@ -296,10 +297,9 @@ async def _observed(self, event: CoreEventMessage) -> None: self.refresh_relationships() def _sync_filter_control(self): - from toad.screens.session_view import SessionView from toad.widgets.conversation import Conversation - view = self.query_ancestor(SessionView).query_one_optional(Conversation) + view = self.query_ancestor(SessionScope).query_one_optional(Conversation) checkboxes = tuple(self.query(Checkbox)) for checkbox in checkboxes: checkbox.display = view is not None diff --git a/src/toad/widgets/thread_identity.py b/src/toad/widgets/thread_identity.py index 535cb78a4..b812f3da5 100644 --- a/src/toad/widgets/thread_identity.py +++ b/src/toad/widgets/thread_identity.py @@ -5,14 +5,13 @@ from toad.core.events import ThreadIdentityChanged from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.thread_identity import SessionIdentity +from toad.session_scope import SessionScope class ShowsThreadIdentity(CoreEventReceiver, Widget): @property - def thread_view(self): - from toad.screens.session_view import SessionView - - return self.query_ancestor(SessionView) + def thread_view(self) -> SessionScope: + return self.query_ancestor(SessionScope) def _on_mount(self) -> None: self.observe_core(self.app.events) diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 0c3dfa3c3..85189ee11 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -2,7 +2,7 @@ from __future__ import annotations from toad.block_navigation import ConversationEntry, FragmentBlockCursor -from toad.screens.session_view import SessionView +from toad.session_scope import SessionScope from agent_comms.ui_model.message_category import OtherCategory @@ -1053,7 +1053,7 @@ class TranscriptHistory(CommittedHistory, LineHistory): @classmethod def initial_state(cls, view): - return view.query_ancestor(SessionView).thread_identity.transcript_state() + return view.query_ancestor(SessionScope).thread_identity.transcript_state() @classmethod def empty_page(cls): diff --git a/src/toad/widgets/wire_history.py b/src/toad/widgets/wire_history.py index f70e45835..30ebda084 100644 --- a/src/toad/widgets/wire_history.py +++ b/src/toad/widgets/wire_history.py @@ -7,7 +7,7 @@ from agent_comms.messages import Message from agent_comms.coordination_errors import StaleRevision -from toad.screens.session_view import SessionView +from toad.session_scope import SessionScope from toad.transcript_preparation import ClosedHistory, PreparedTranscriptPage from toad.transcript_source_preparation import HistorySourceSnapshot from toad.widgets.message_notifications import NothingToRead, PaintedRead, VisibleReadMarking @@ -56,7 +56,7 @@ def source_identity(self): @property def can_draw_on_screen(self) -> bool: return (super().can_draw_on_screen - and self.view.query_ancestor(SessionView).is_current) + and self.view.query_ancestor(SessionScope).is_current) async def source_is_current(self, snapshot: HistorySourceSnapshot) -> bool: """Whether the route still selects this source's bus and nothing replaced the history.""" diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 8b329e5a2..5fa132f65 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -24,6 +24,7 @@ ) from toad.conversation_kind import ConversationKind +from toad.session_scope import SessionScope from toad.line_blocks import Body, Gap, LineBlock, MutedRole, Summary from toad.message_rows import MessageRow, WireMessageStyle from toad.transcript_preparation import ( @@ -342,10 +343,9 @@ def status_thread(self, access): def command_context(self, view): from toad.target_commands import TargetContext - from toad.screens.session_view import SessionView return TargetContext(view.app, self.loader.comms, self.loader.conversation.target, self.loader.me, - self.loader.project, view.query_ancestor(SessionView).id) + self.loader.project, view.query_ancestor(SessionScope).id) async def submit_input(self, view, event) -> None: conversation = self.loader.conversation diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 7e5f1b33a..8266f26e1 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -12,10 +12,10 @@ from toad.frame_presentation import TonePaint from toad.widget_view import AUTO, FILL, WidgetView from toad.session_presentation import NativeSessionSurface +from toad.session_scope import SessionScope if TYPE_CHECKING: from toad.app import ToadApp - from toad.screens.session_view import SessionView class WorkspaceHeader(TonePaint, WidgetView, Horizontal): @@ -83,7 +83,7 @@ def arrange(self, parent: Widget, children: list[Widget], size: Size, )) return placements - async def select(self, view: "SessionView") -> None: + async def select(self, view: "SessionScope") -> None: roster = self.channels.roster roster.navigation.capture() roster.set_enabled(view.shows_channels) diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 282c81f6f..515d3fff3 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -7,13 +7,13 @@ from agent_comms.declared_family import DeclaredFamily from toad.constants import WELCOME +from toad.session_scope import SessionOrigin, SessionScope if TYPE_CHECKING: from toad.app import ToadApp - from toad.screens.session_view import SessionOrigin, SessionView -def welcome_view() -> "SessionView": +def welcome_view() -> "SessionScope": from toad.screens.welcome import WelcomeScreen return WelcomeScreen() @@ -23,12 +23,12 @@ class WorkspaceSource(DeclaredFamily, affix="WorkspaceSource"): """Owns the selected view, including closing it outside the workspace.""" @abstractmethod - def matches(self, view: "SessionView") -> bool: ... + def matches(self, view: "SessionScope") -> bool: ... - def current(self, view: "SessionView") -> bool: + def current(self, view: "SessionScope") -> bool: return False - def shown(self, view: "SessionView") -> bool: + def shown(self, view: "SessionScope") -> bool: return False @abstractmethod @@ -79,11 +79,11 @@ def sidebars(self): class BoundWorkspaceSource(WorkspaceSource): - def __init__(self, view: "SessionView") -> None: + def __init__(self, view: "SessionScope") -> None: self.view = view @abstractmethod - def current(self, view: "SessionView") -> bool: ... + def current(self, view: "SessionScope") -> bool: ... @property def identity(self): @@ -140,11 +140,11 @@ class WorkspaceSessions: def __init__(self, app: "ToadApp") -> None: self.app = app # Views the workspace always has; they are not tabs and never close. - self.resident: dict[str, Callable[[], SessionView]] = {WELCOME: welcome_view} - self.views: dict[str, SessionView] = {} + self.resident: dict[str, Callable[[], SessionScope]] = {WELCOME: welcome_view} + self.views: dict[str, SessionScope] = {} self.source: WorkspaceSource = DetachedWorkspaceSource() - def require(self, identity: str) -> "SessionView": + def require(self, identity: str) -> "SessionScope": return self.views[identity] def origin(self, identity: str) -> "SessionOrigin | None": @@ -154,7 +154,7 @@ def origin(self, identity: str) -> "SessionOrigin | None": return view.origin return None - async def prepare(self, identity: str) -> "SessionView": + async def prepare(self, identity: str) -> "SessionScope": if view := self.views.get(identity): return view view = (self.app.session_navigation.get(identity) or self.resident[identity])() @@ -165,7 +165,7 @@ async def prepare(self, identity: str) -> "SessionView": await host.mount(view) return view - async def select(self, identity: str) -> "SessionView": + async def select(self, identity: str) -> "SessionScope": destination = await self.prepare(identity) if self.source.shown(destination): return destination @@ -194,7 +194,7 @@ async def select(self, identity: str) -> "SessionView": async def retire(self) -> None: self.source = await self.source.retire() - def owns(self, view: "SessionView") -> bool: + def owns(self, view: "SessionScope") -> bool: return self.source.current(view) def visible_target(self, root): @@ -214,10 +214,8 @@ def release_frame_callback(self, owner, callback) -> bool: chrome has no session binding. The frame still owns widget attachment and terminal writes; the owner's message pump executes source I/O. """ - from toad.screens.session_view import SessionView - for view in owner.walk_ancestors(with_self=True): - if isinstance(view, SessionView): + if isinstance(view, SessionScope): if not self.owns(view): return False break diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py new file mode 100644 index 000000000..135c9a1e2 --- /dev/null +++ b/tests/app/test_session_scope.py @@ -0,0 +1,25 @@ +"""Session consumers discover the existing mounted owner without a screen cycle.""" +import sys + +from toad.session_scope import SessionScope +from support.agents import peer + + +async def test_scope_import_and_real_conversation_ancestor(workspace): + assert "toad.screens.session_view" not in sys.modules + async with workspace.app(agent=peer("echo")) as ui: + app, _pilot = ui.textual + first = ui.selected_tab + view = app.selected_session + assert ui.conversation.query_ancestor(SessionScope) is view + await ui.until_agent_ready() + await ui.write_draft("session scope journey") + await ui.press("enter") + await ui.until_shows("PEER_ANSWER session scope journey") + second = await ui.new_session() + assert second != first + assert ui.conversation.query_ancestor(SessionScope) is app.selected_session + await ui.select_tab(first) + assert app.selected_session is view + assert ui.conversation.query_ancestor(SessionScope) is view + await ui.until_shows("PEER_ANSWER session scope journey") From 84478dc9c3bdd3eb4ab3a5c98e4b6e229518394e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 04:20:17 -0400 Subject: [PATCH 02/43] P401: inherit native ancestry and select the private test root --- src/toad/session_scope.py | 78 ++++++++++++++--------------- src/toad/widgets/thread_identity.py | 2 +- tests/app/test_session_scope.py | 6 ++- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 5216a78bb..097a4c1ba 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -1,9 +1,9 @@ -"""The session contract consumed by descendants and workspace owners. +"""The session capability consumed by descendants and workspace owners. -Like ProjectPathOwner, this abstract capability has no ABC metaclass: Textual -owns its widgets' metaclass. Native widget methods and data descriptors remain -implemented by the view's existing parents, not by a forwarding wrapper. +DOMNode owns native ancestry, queries and descriptors. SessionView supplies +session behavior; the capability imports neither a widget nor a screen. """ + from __future__ import annotations from abc import abstractmethod @@ -12,47 +12,26 @@ from typing import TYPE_CHECKING from agent_comms.route_selection import RouteSelection +from textual.dom import DOMNode + from toad.project_path_owner import ProjectPathOwner if TYPE_CHECKING: from textual.command import Provider - from textual.dom import DOMNode + from toad.agent_schema import AgentDefinition + from toad.app import ToadApp from toad.navigation_target import NavigationContext from toad.session_presentation import OperationalSessionPresentation from toad.thread_identity import SessionIdentity -class SessionOrigin: - """The existing agent/project origin of sessions and histories.""" - - _agent: AgentDefinition | None - - @property - def origin(self) -> SessionOrigin: - return self - - def transcript_scope(self) -> tuple[str, str] | None: - return self.thread_identity.transcript_scope() +class SessionScope(ProjectPathOwner, DOMNode): + """A logical session, without a dependency on its concrete widget.""" - def resolve_thread(self) -> str: - identity = self.thread_identity - selected = RouteSelection.capture(identity.wire_root()) - service = self.app.coordination_access.require(selected).service - return identity.resolve_thread(service.registry, self.project_path) - - def has_agent(self) -> bool: - return self._agent is not None - - -class SessionScope(ProjectPathOwner): - """What a mounted logical session answers, independently of its screen.""" - - # Existing native data descriptors and concrete-view state. An abstract - # property here would shadow their inherited setters or instance attributes. - id: str | None - display: bool - screen: DOMNode + # Concrete view state stays data: abstract properties would shadow inherited + # setters or instance attributes. Native descriptors come from DOMNode. + app: ToadApp COMMANDS: set[type[Provider]] AUTO_FOCUS: str shows_channels: bool @@ -68,6 +47,10 @@ def is_current(self) -> bool: ... @abstractmethod def display_name(self) -> str: ... + @property + @abstractmethod + def navigation_target_name(self) -> str | None: ... + @property @abstractmethod def origin(self) -> SessionOrigin | None: ... @@ -100,10 +83,27 @@ def activate_session(self) -> None: ... @abstractmethod def close_core_events(self) -> None: ... - @abstractmethod - def query_one_optional[Node]( - self, selector: str | type[Node], expect_type: type[Node] | None = None, - ) -> Node | None: ... - @abstractmethod def remove(self) -> Awaitable[None]: ... + + +class SessionOrigin(SessionScope): + """An operational view that supplies the origin of sessions and histories.""" + + _agent: AgentDefinition | None + + @property + def origin(self) -> SessionOrigin: + return self + + def transcript_scope(self) -> tuple[str, str] | None: + return self.thread_identity.transcript_scope() + + def resolve_thread(self) -> str: + identity = self.thread_identity + selected = RouteSelection.capture(identity.wire_root()) + service = self.app.coordination_access.require(selected).service + return identity.resolve_thread(service.registry, self.project_root) + + def has_agent(self) -> bool: + return self._agent is not None diff --git a/src/toad/widgets/thread_identity.py b/src/toad/widgets/thread_identity.py index b812f3da5..b6d76117b 100644 --- a/src/toad/widgets/thread_identity.py +++ b/src/toad/widgets/thread_identity.py @@ -4,8 +4,8 @@ from toad.core.events import ThreadIdentityChanged from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver -from toad.thread_identity import SessionIdentity from toad.session_scope import SessionScope +from toad.thread_identity import SessionIdentity class ShowsThreadIdentity(CoreEventReceiver, Widget): diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index 135c9a1e2..d302f1466 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -1,12 +1,16 @@ """Session consumers discover the existing mounted owner without a screen cycle.""" + import sys -from toad.session_scope import SessionScope +from agent_comms.launch_root import LaunchRoot from support.agents import peer +from toad.session_scope import SessionScope + async def test_scope_import_and_real_conversation_ancestor(workspace): assert "toad.screens.session_view" not in sys.modules + LaunchRoot.from_path(workspace.roots.wire).install() async with workspace.app(agent=peer("echo")) as ui: app, _pilot = ui.textual first = ui.selected_tab From 5bb6d9df7f448a67e5ba40a7dc341ca050d6d456 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 04:56:50 -0400 Subject: [PATCH 03/43] P401: model unavailable origins and let views return sidebar focus --- src/toad/application_actions.py | 7 +++- src/toad/screens/comms.py | 6 +-- src/toad/screens/main.py | 15 ++++--- src/toad/screens/session_view.py | 20 ++------- src/toad/screens/welcome.py | 4 +- src/toad/screens/workspace.py | 4 +- src/toad/session_navigation.py | 7 ++-- src/toad/session_scope.py | 72 +++++++++++++++++++++++--------- src/toad/thread_navigation.py | 12 +++--- src/toad/widgets/conversation.py | 3 +- src/toad/widgets/side_bar.py | 8 +--- src/toad/workspace_sessions.py | 18 ++++---- tests/app/test_session_scope.py | 9 +++- 13 files changed, 105 insertions(+), 80 deletions(-) diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index 44821d3fa..90b522fa2 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -9,6 +9,7 @@ from toad.widget_actions import ApplicationAction, KeyboundAction, MenuAction, VimApplicationAction from toad.target_commands import TargetContext from toad.thread_actions import ThreadAction +from agent_comms.errors import RelationViolationError from agent_comms.cli_commands import NewThreadCliCommand from agent_comms.channel_targets import ProjectTarget @@ -29,8 +30,10 @@ class NewThreadAction(ApplicationAction, MenuAction): async def apply(self, app: ToadApp) -> None: mode = app.selected_mode - source = app.workspace_sessions.origin(mode) - project = source.project_path if source is not None else app.project_dir + try: + project = app.workspace_sessions.origin(mode).require().project_root + except RelationViolationError: + project = app.project_dir app.run_worker(partial(self.collect, app, mode, project), group="new-session", exclusive=True) async def collect(self, app, mode, project): diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index ecf2494c9..3b48b1042 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -232,11 +232,11 @@ def action_focus_prompt(self) -> None: if chat := self.query_one_optional(CommsChatView): chat.prepare_prompt() - def sidebar_focus_target(self) -> Widget | None: + def focus_sidebar_return(self) -> None: if chat := self.query_one_optional(CommsChatView): target = chat.prompt.prompt_text_area - return target if target.focusable else None - return None + if target.focusable: + self.screen.set_focus(target, scroll_visible=False) @on(SideBar.Dismiss) def on_side_bar_dismiss(self, event: SideBar.Dismiss) -> None: diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 458fb0353..9c71881ee 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -38,7 +38,7 @@ from toad.app import ToadApp from toad.navigation_target import NavigationContext, NavigationOwner from toad.screens.session_view import SessionView -from toad.session_scope import SessionOrigin +from toad.session_scope import ViewSessionOrigin from toad.widgets.message_notifications import NothingToRead, PaintedRead, ReadWork from toad.screens.mcp_inventory import MCPInventoryScreen from toad.session_presentation import OperationalSessionPresentation @@ -190,7 +190,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(SessionOrigin, SessionView, NavigationOwner, can_focus=False): +class MainScreen(ViewSessionOrigin, SessionView, NavigationOwner, can_focus=False): footer_compact = True AUTO_FOCUS = "Conversation Prompt TextArea" @@ -561,12 +561,11 @@ def action_focus_prompt(self) -> None: if conversation := self.query_one_optional(Conversation): conversation.focus_prompt() - def sidebar_focus_target(self) -> Widget | None: - conversation = self.query_one_optional(Conversation) - if conversation is None: - return None - target = conversation.prompt.prompt_text_area - return target if target.focusable else None + def focus_sidebar_return(self) -> None: + if conversation := self.query_one_optional(Conversation): + target = conversation.prompt.prompt_text_area + if target.focusable: + self.screen.set_focus(target, scroll_visible=False) async def action_go_home(self) -> None: await self.app.select_session("store") diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 780b59ca0..8067a27f1 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -1,6 +1,5 @@ """Logical session surfaces hosted by the one native WorkspaceScreen.""" from textual.containers import Vertical -from textual.widget import Widget from textual.reactive import reactive from pathlib import Path from contextlib import contextmanager @@ -9,7 +8,7 @@ from toad.core.source_events import TranscriptSourceWorkFinished from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.thread_identity import SessionIdentity, NoThreadIdentity -from toad.session_scope import SessionOrigin, SessionScope +from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope from toad.widgets.message_notifications import NothingToRead, PaintedRead, VisibleReadMarking class SessionView(VisibleReadMarking, CoreEventReceiver, Vertical, SessionScope): @@ -17,11 +16,6 @@ class SessionView(VisibleReadMarking, CoreEventReceiver, Vertical, SessionScope) sub_title = reactive("") DEFAULT_CSS = "SessionView { width: 1fr; height: 1fr; }" - AUTO_FOCUS = "" - COMMANDS = set() - footer_compact = False - shows_channels = True - def __init__(self, *args, **kwargs): self._identity: SessionIdentity = NoThreadIdentity() super().__init__(*args, **kwargs) @@ -45,9 +39,9 @@ def coordination_root(self) -> str | None: return self._identity.wire_root() @property - def origin(self) -> SessionOrigin | None: + def origin(self) -> SessionOrigin: """What a session or history requested from this view starts from.""" - return None + return NoSessionOrigin(str(self)) def belongs_to_wire(self, root) -> bool: """Unbound views may project the selected wire; bound views stay on it.""" @@ -69,18 +63,10 @@ async def open_direct_messages(self) -> None: """Open this session's direct-message destination when it has one.""" self.notify("Direct messages are unavailable from this view", title="Direct messages") - def sidebar_focus_target(self) -> Widget | None: - return None - def sidebars(self): """Supply this view's native sidebar resources through its declaration.""" return () - @property - def navigation_target_name(self) -> str | None: - """The displayed destination, independent of sidebar click intent.""" - return None - @property def thread_identity(self) -> SessionIdentity: return self._identity diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 6052e2a85..294da3f1d 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -23,7 +23,7 @@ from toad.navigation_target import FeedTarget, NavigationContext, NavigationOwner, NavigationTarget from toad.navigation_target import channel_target, ThreadTarget from toad.screens.session_view import SessionView -from toad.session_scope import SessionOrigin +from toad.session_scope import ViewSessionOrigin from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction from toad.widgets.sidebar_tree import RowReconciliation, SidebarChange, SidebarGroup, SidebarModelView, TargetTree @@ -167,7 +167,7 @@ async def present_selected(self, event: CoreEventMessage) -> None: self.present() -class WelcomeScreen(SessionOrigin, SessionView, NavigationOwner, can_focus=False): +class WelcomeScreen(ViewSessionOrigin, SessionView, NavigationOwner, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" BINDINGS = [ diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index 4b303742e..6584cd8b2 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -100,8 +100,8 @@ def belongs_to_wire(self, root) -> bool: def start_sidebar_navigation(self, sidebar) -> None: self.frame_presentation.defer(sidebar, sidebar.navigation.start) - def sidebar_focus_target(self): - return self.app.workspace_sessions.source.sidebar_focus_target() + def focus_sidebar_return(self) -> None: + self.app.workspace_sessions.source.focus_sidebar_return() def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: if action == "show_sidebar": diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index be2b8460f..bba529afd 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -12,7 +12,7 @@ from agent_comms.presentation import NoRevision, ReadState from agent_comms.revision_views import RegistrationsView -from agent_comms.errors import UnknownChannelError, UnregisteredThreadError +from agent_comms.errors import RelationViolationError, UnknownChannelError, UnregisteredThreadError from agent_comms.route_selection import RouteChanged, root_is_current from agent_comms.thread_identity import ThreadIncarnation from toad.navigation_preparation import CommsNavigationRequest @@ -185,8 +185,9 @@ async def history(self, *, owner_mode: str, project_path: Path, me: str, from toad.thread_navigation import ThreadOrigin app = self.app - source = app.workspace_sessions.origin(owner_mode) - if source is None: + try: + source = app.workspace_sessions.origin(owner_mode).require() + except RelationViolationError: app.show_error("The owning agent tab was closed", "Comms target unavailable") return app.selected_mode origin = ThreadOrigin.capture(app, source) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 097a4c1ba..5ee00bc18 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -1,16 +1,13 @@ -"""The session capability consumed by descendants and workspace owners. - -DOMNode owns native ancestry, queries and descriptors. SessionView supplies -session behavior; the capability imports neither a widget nor a screen. -""" +"""Logical session capabilities, independent of concrete widgets and screens.""" from __future__ import annotations from abc import abstractmethod from collections.abc import Awaitable from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar, Never +from agent_comms.errors import RelationViolationError from agent_comms.route_selection import RouteSelection from textual.dom import DOMNode @@ -26,16 +23,47 @@ from toad.thread_identity import SessionIdentity +class SessionOrigin: + """An origin answers for itself, including when its view is unavailable.""" + + @abstractmethod + def transcript_scope(self) -> tuple[str, str] | None: ... + + @abstractmethod + def has_agent(self) -> bool: ... + + @abstractmethod + def resolve_thread(self) -> str: ... + + @abstractmethod + def require(self) -> ViewSessionOrigin: ... + + +class NoSessionOrigin(SessionOrigin): + def __init__(self, view: str = "unavailable view") -> None: + self.view = view + + def transcript_scope(self) -> None: + return None + + def has_agent(self) -> bool: + return False + + def require(self) -> Never: + raise RelationViolationError(f"View {self.view!r} has no session origin") + + def resolve_thread(self) -> Never: + self.require() + + class SessionScope(ProjectPathOwner, DOMNode): - """A logical session, without a dependency on its concrete widget.""" + """The session answers read by ancestors and workspace owners.""" - # Concrete view state stays data: abstract properties would shadow inherited - # setters or instance attributes. Native descriptors come from DOMNode. app: ToadApp - COMMANDS: set[type[Provider]] - AUTO_FOCUS: str - shows_channels: bool - footer_compact: bool + COMMANDS: ClassVar[set[type[Provider]]] = set() + AUTO_FOCUS = "" + shows_channels = True + footer_compact = False presentation: OperationalSessionPresentation navigation_context: NavigationContext @@ -48,12 +76,13 @@ def is_current(self) -> bool: ... def display_name(self) -> str: ... @property - @abstractmethod - def navigation_target_name(self) -> str | None: ... + def navigation_target_name(self) -> str | None: + """Views without a displayed destination have no navigation name.""" + return None @property @abstractmethod - def origin(self) -> SessionOrigin | None: ... + def origin(self) -> SessionOrigin: ... @property @abstractmethod @@ -65,8 +94,8 @@ def belongs_to_wire(self, root: str | Path) -> bool: ... @abstractmethod def sidebars(self) -> tuple[DOMNode, ...]: ... - @abstractmethod - def sidebar_focus_target(self) -> DOMNode | None: ... + def focus_sidebar_return(self) -> None: + """A view without a sidebar return target has nothing to focus.""" @abstractmethod async def prepare_presentation(self) -> None: ... @@ -87,8 +116,8 @@ def close_core_events(self) -> None: ... def remove(self) -> Awaitable[None]: ... -class SessionOrigin(SessionScope): - """An operational view that supplies the origin of sessions and histories.""" +class ViewSessionOrigin(SessionOrigin, SessionScope): + """The existing operational agent/project origin shared by real views.""" _agent: AgentDefinition | None @@ -99,6 +128,9 @@ def origin(self) -> SessionOrigin: def transcript_scope(self) -> tuple[str, str] | None: return self.thread_identity.transcript_scope() + def require(self) -> ViewSessionOrigin: + return self + def resolve_thread(self) -> str: identity = self.thread_identity selected = RouteSelection.capture(identity.wire_root()) diff --git a/src/toad/thread_navigation.py b/src/toad/thread_navigation.py index 246e4db54..2c6f0f910 100644 --- a/src/toad/thread_navigation.py +++ b/src/toad/thread_navigation.py @@ -6,24 +6,25 @@ from pathlib import Path from typing import TYPE_CHECKING +from agent_comms.errors import RelationViolationError from toad.navigation_preparation import OpenThread, ThreadNavigationRequest, ThreadOpening if TYPE_CHECKING: from toad.app import ToadApp from toad.screens.main import MainScreen - from toad.session_scope import SessionOrigin + from toad.session_scope import ViewSessionOrigin from toad.navigation_preparation import ThreadNavigation @dataclass(frozen=True) class ThreadOrigin: - source: SessionOrigin + source: ViewSessionOrigin selected_mode: str root: str | None identity: str @classmethod - def capture(cls, app: ToadApp, source: SessionOrigin) -> ThreadOrigin: + def capture(cls, app: ToadApp, source: ViewSessionOrigin) -> ThreadOrigin: return cls(source, app.selected_mode, source.coordination_root, source.thread_identity.thread_name()) def current(self, navigator: ThreadNavigator, owner_mode: str) -> bool: @@ -41,8 +42,9 @@ def __init__(self, app: ToadApp) -> None: async def open(self, *, owner_mode: str, project_path: Path, target: str) -> str: app = self.app - source = app.workspace_sessions.origin(owner_mode) - if source is None: + try: + source = app.workspace_sessions.origin(owner_mode).require() + except RelationViolationError: return app.selected_mode origin = ThreadOrigin.capture(app, source) access = app.coordination_access diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 6d099fa59..40f286fb9 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -356,8 +356,7 @@ def operational_sources(self): def transcript_scope(self) -> tuple[str, str] | None: """The native session identity whose canonical transcript this view shows.""" - origin = self.query_ancestor(SessionScope).origin - return origin.transcript_scope() if origin is not None else None + return self.query_ancestor(SessionScope).origin.transcript_scope() def status_thread(self) -> StatusThread: scope = self.transcript.binding.thread_scope diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 9cb155c16..5d4b750b4 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -894,12 +894,8 @@ def toggle(self, *, focus: bool = True) -> None: collapsed = not self.collapsed return_focus = collapsed and (focus or self.has_focus_within) if return_focus and self.is_mounted and self.screen.is_current: - screen = self.screen - target = screen.sidebar_focus_target() - if target is not None: - # Native Hide otherwise reconstructs a full geometry-sorted - # focus chain before the queued Dismiss can return to input. - screen.set_focus(target, scroll_visible=False) + # Return input focus before native Hide reconstructs its focus chain. + self.screen.focus_sidebar_return() self.store_collapsed(collapsed) if collapsed: if return_focus: diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 515d3fff3..f2a25dd70 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -7,7 +7,7 @@ from agent_comms.declared_family import DeclaredFamily from toad.constants import WELCOME -from toad.session_scope import SessionOrigin, SessionScope +from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope if TYPE_CHECKING: from toad.app import ToadApp @@ -44,7 +44,7 @@ def commands(self) -> set: ... def coordination_root(self): ... @abstractmethod - def sidebar_focus_target(self): ... + def focus_sidebar_return(self) -> None: ... @abstractmethod def sidebars(self): @@ -71,8 +71,8 @@ def commands(self): def coordination_root(self): return None - def sidebar_focus_target(self): - return None + def focus_sidebar_return(self) -> None: + """A detached workspace has no return target.""" def sidebars(self): return () @@ -108,8 +108,8 @@ def commands(self): def coordination_root(self): return self.view.coordination_root - def sidebar_focus_target(self): - return self.view.sidebar_focus_target() + def focus_sidebar_return(self) -> None: + self.view.focus_sidebar_return() def sidebars(self): return self.view.sidebars() @@ -147,12 +147,12 @@ def __init__(self, app: "ToadApp") -> None: def require(self, identity: str) -> "SessionScope": return self.views[identity] - def origin(self, identity: str) -> "SessionOrigin | None": - """What sessions and histories requested from ``identity`` start from; None once it closed.""" + def origin(self, identity: str) -> SessionOrigin: + """The original source, or its explicit unavailable member once closed.""" if self.app.session_navigation.get(identity) is not None or identity in self.resident: if view := self.views.get(identity): return view.origin - return None + return NoSessionOrigin(identity) async def prepare(self, identity: str) -> "SessionScope": if view := self.views.get(identity): diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index d302f1466..a90dab02e 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -2,14 +2,21 @@ import sys +import pytest +from agent_comms.errors import RelationViolationError from agent_comms.launch_root import LaunchRoot from support.agents import peer -from toad.session_scope import SessionScope +from toad.session_scope import NoSessionOrigin, SessionScope async def test_scope_import_and_real_conversation_ancestor(workspace): assert "toad.screens.session_view" not in sys.modules + unavailable = NoSessionOrigin("closed-test-view") + assert unavailable.transcript_scope() is None + assert not unavailable.has_agent() + with pytest.raises(RelationViolationError, match="closed-test-view"): + unavailable.resolve_thread() LaunchRoot.from_path(workspace.roots.wire).install() async with workspace.app(agent=peer("echo")) as ui: app, _pilot = ui.textual From cc6067a3a2d192f89dfb5d3a1c4b0656497050a7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 04:59:49 -0400 Subject: [PATCH 04/43] P401: exercise sidebar return focus and closed-origin behavior --- tests/app/test_session_scope.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index a90dab02e..cab7c8739 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -27,6 +27,15 @@ async def test_scope_import_and_real_conversation_ancestor(workspace): await ui.write_draft("session scope journey") await ui.press("enter") await ui.until_shows("PEER_ANSWER session scope journey") + sidebar = app.workspace_chrome.channels + if sidebar.collapsed: + sidebar.toggle() + await ui.step() + sidebar.toggle() + await ui.until( + lambda: app.focused is ui.conversation.prompt.prompt_text_area, + why="collapsing the sidebar returns input focus", + ) second = await ui.new_session() assert second != first assert ui.conversation.query_ancestor(SessionScope) is app.selected_session @@ -34,3 +43,7 @@ async def test_scope_import_and_real_conversation_ancestor(workspace): assert app.selected_session is view assert ui.conversation.query_ancestor(SessionScope) is view await ui.until_shows("PEER_ANSWER session scope journey") + await ui.close_tab(second) + assert not app.workspace_sessions.origin(second).has_agent() + with pytest.raises(RelationViolationError): + app.workspace_sessions.origin(second).resolve_thread() From af941ed6a21a72970c3da704fa01189097fb0cef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 06:02:38 -0400 Subject: [PATCH 05/43] P401: inherit the UI-free navigation owner across session scopes --- src/toad/navigation_context.py | 34 ++++++++++++++++++++ src/toad/navigation_target.py | 25 +------------- src/toad/screens/comms.py | 5 ++- src/toad/screens/main.py | 4 +-- src/toad/screens/welcome.py | 5 +-- src/toad/session_scope.py | 5 ++- tests/native/test_native_client_requests.py | 3 +- tests/native/test_native_editor_queue.py | 3 +- tests/support/native_steps.py | 3 +- tests/support/ui.py | 5 +-- tests/textual/test_conversation_value.py | 3 +- tests/textual/test_navigation_route_reads.py | 3 +- tests/textual/test_sidebar_destinations.py | 3 +- tests/textual/test_tab_underline.py | 3 +- 14 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 src/toad/navigation_context.py diff --git a/src/toad/navigation_context.py b/src/toad/navigation_context.py new file mode 100644 index 000000000..c11c043b4 --- /dev/null +++ b/src/toad/navigation_context.py @@ -0,0 +1,34 @@ +"""Navigation context and its owning capability, without UI imports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from toad.app import ToadApp + from toad.navigation_target import NavigationTarget + + +@dataclass(frozen=True) +class NavigationContext: + app: ToadApp + owner_mode: str + project_path: Path + actor: str + + +class NavigationOwner: + """Views supply context, targets supply behavior, App owns execution.""" + + @property + def navigation_context(self) -> NavigationContext: + raise NotImplementedError + + def remember_direct_target(self, target: str) -> None: + """Optional view-local return-to-DM intent.""" + + async def open_sidebar_target(self, target: NavigationTarget) -> str: + target.selected(self) + return await target.open(self.navigation_context) diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index a0e75c392..f0ee85619 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -6,7 +6,6 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.channel_targets import CommandTarget, ChannelTarget as CoreChannelTarget, ThreadTarget as CoreThreadTarget from dataclasses import dataclass, field -from pathlib import Path from typing import TYPE_CHECKING, ClassVar from toad.conversation_kind import ConversationKind, ChannelConversation, DmConversation, IrcConversation @@ -14,29 +13,7 @@ if TYPE_CHECKING: from toad.app import ToadApp - - -@dataclass(frozen=True) -class NavigationContext: - app: ToadApp - owner_mode: str - project_path: Path - actor: str - - -class NavigationOwner: - """Views supply context, targets supply behavior, App owns execution.""" - - @property - def navigation_context(self) -> NavigationContext: - raise NotImplementedError - - def remember_direct_target(self, target: str) -> None: - """Optional view-local return-to-DM intent.""" - - async def open_sidebar_target(self, target: NavigationTarget) -> str: - target.selected(self) - return await target.open(self.navigation_context) + from toad.navigation_context import NavigationContext, NavigationOwner @dataclass(frozen=True) diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 3b48b1042..fcf560d07 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -11,7 +11,6 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.events import ScreenResume -from textual.widget import Widget from textual.worker import WorkerCancelled from textual.widgets import Static @@ -24,13 +23,13 @@ from toad.widgets.comms_sidebar import CoordinationStatus, CommsSidebar from toad.session_tracker import SidebarState from toad.widgets.side_bar import SideBar -from toad.navigation_target import NavigationContext, NavigationOwner +from toad.navigation_context import NavigationContext from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import SideBar, ThreadSidebar, TabHistoryControls from toad.widgets.thread_comms import RelationshipSort, ThreadCommsSidebar -class CommsScreen(SessionView, NavigationOwner, can_focus=False): +class CommsScreen(SessionView, can_focus=False): """A channel or DM represented as a native concurrent Toad session.""" AUTO_FOCUS = "CommsChatView Prompt TextArea" diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 9c71881ee..a9a688138 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -36,7 +36,7 @@ from toad.acp.agent_session import SessionDisposition from toad.agent_schema import AgentDefinition from toad.app import ToadApp -from toad.navigation_target import NavigationContext, NavigationOwner +from toad.navigation_context import NavigationContext from toad.screens.session_view import SessionView from toad.session_scope import ViewSessionOrigin from toad.widgets.message_notifications import NothingToRead, PaintedRead, ReadWork @@ -190,7 +190,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(ViewSessionOrigin, SessionView, NavigationOwner, can_focus=False): +class MainScreen(ViewSessionOrigin, SessionView, can_focus=False): footer_compact = True AUTO_FOCUS = "Conversation Prompt TextArea" diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 294da3f1d..9791a8837 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -20,7 +20,8 @@ from toad.core.events import SessionSelected from toad.core_event_carrier import CoreEventMessage from toad.format_path import format_path -from toad.navigation_target import FeedTarget, NavigationContext, NavigationOwner, NavigationTarget +from toad.navigation_context import NavigationContext +from toad.navigation_target import FeedTarget, NavigationTarget from toad.navigation_target import channel_target, ThreadTarget from toad.screens.session_view import SessionView from toad.session_scope import ViewSessionOrigin @@ -167,7 +168,7 @@ async def present_selected(self, event: CoreEventMessage) -> None: self.present() -class WelcomeScreen(ViewSessionOrigin, SessionView, NavigationOwner, can_focus=False): +class WelcomeScreen(ViewSessionOrigin, SessionView, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" BINDINGS = [ diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 5ee00bc18..be7a7c43c 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -11,6 +11,7 @@ from agent_comms.route_selection import RouteSelection from textual.dom import DOMNode +from toad.navigation_context import NavigationOwner from toad.project_path_owner import ProjectPathOwner if TYPE_CHECKING: @@ -18,7 +19,6 @@ from toad.agent_schema import AgentDefinition from toad.app import ToadApp - from toad.navigation_target import NavigationContext from toad.session_presentation import OperationalSessionPresentation from toad.thread_identity import SessionIdentity @@ -56,7 +56,7 @@ def resolve_thread(self) -> Never: self.require() -class SessionScope(ProjectPathOwner, DOMNode): +class SessionScope(NavigationOwner, ProjectPathOwner, DOMNode): """The session answers read by ancestors and workspace owners.""" app: ToadApp @@ -65,7 +65,6 @@ class SessionScope(ProjectPathOwner, DOMNode): shows_channels = True footer_compact = False presentation: OperationalSessionPresentation - navigation_context: NavigationContext @property @abstractmethod diff --git a/tests/native/test_native_client_requests.py b/tests/native/test_native_client_requests.py index bc32bb15f..da47458ef 100644 --- a/tests/native/test_native_client_requests.py +++ b/tests/native/test_native_client_requests.py @@ -5,7 +5,8 @@ from support.native import native_session from toad.db import DB -from toad.navigation_target import NavigationContext, channel_target +from toad.navigation_context import NavigationContext +from toad.navigation_target import channel_target from toad.screens.permissions import PermissionReview from toad.widgets.terminal_tool import TerminalTool diff --git a/tests/native/test_native_editor_queue.py b/tests/native/test_native_editor_queue.py index 7f40db5d2..d2b732807 100644 --- a/tests/native/test_native_editor_queue.py +++ b/tests/native/test_native_editor_queue.py @@ -3,7 +3,8 @@ from agent_comms.pi_vocabulary import HighThinkingLevel from support.native import native_session -from toad.navigation_target import NavigationContext, channel_target +from toad.navigation_context import NavigationContext +from toad.navigation_target import channel_target from toad.widgets.comms_menu import ContextMenu from toad.widgets.prompt import AgentInfo, QueueSummary from toad.widgets.transcript_history import TranscriptHistory diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index 20017a5f4..0878c3387 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -8,7 +8,8 @@ from agent_comms.input_disposition import InputDispositions from toad.conversation_kind import ChannelConversation, DmConversation from toad.core import input_events -from toad.navigation_target import DirectTarget, NavigationContext, channel_target +from toad.navigation_context import NavigationContext +from toad.navigation_target import DirectTarget, channel_target from toad.widgets.comms_chat import CommsChatView from toad.notification_status import NotificationStatus diff --git a/tests/support/ui.py b/tests/support/ui.py index 097b2115b..f7da1154c 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING from support.agents import AppAgents +from toad.navigation_context import NavigationContext if TYPE_CHECKING: from textual.pilot import Pilot @@ -295,7 +296,7 @@ async def new_session(self) -> str: return details.mode_name async def open_channel(self, target: str, *, actor: str, owner: str | None = None) -> str: - from toad.navigation_target import NavigationContext, channel_target + from toad.navigation_target import channel_target context = NavigationContext(self.app, owner or self.selected_tab, self.app.project_dir, actor) identity = await channel_target(target).open(context) @@ -303,7 +304,7 @@ async def open_channel(self, target: str, *, actor: str, owner: str | None = Non return identity async def open_direct(self, thread: str, *, actor: str, owner: str | None = None) -> str: - from toad.navigation_target import DirectTarget, NavigationContext + from toad.navigation_target import DirectTarget context = NavigationContext(self.app, owner or self.selected_tab, self.app.project_dir, actor) identity = await DirectTarget(thread).open(context) diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 98216b87d..325e55579 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -2,7 +2,8 @@ from agent_comms.launch_root import LaunchRoot from toad.conversation_kind import ChannelConversation, DmConversation -from toad.navigation_target import DirectTarget, NavigationContext, channel_target +from toad.navigation_context import NavigationContext +from toad.navigation_target import DirectTarget, channel_target from toad.widgets.comms_chat import CommsChatView from toad.widgets.channel_activity import ChannelActivity from toad.widgets.observed_thread_activity import NoStatusThread diff --git a/tests/textual/test_navigation_route_reads.py b/tests/textual/test_navigation_route_reads.py index f438907a1..6bfc9479d 100644 --- a/tests/textual/test_navigation_route_reads.py +++ b/tests/textual/test_navigation_route_reads.py @@ -5,7 +5,8 @@ from unittest.mock import patch from toad.navigation_preparation import CommsNavigationRequest, ThreadNavigationRequest -from toad.navigation_target import NavigationContext, ThreadTarget, channel_target +from toad.navigation_context import NavigationContext +from toad.navigation_target import ThreadTarget, channel_target class Gate: diff --git a/tests/textual/test_sidebar_destinations.py b/tests/textual/test_sidebar_destinations.py index 7bb4e36f8..689aada49 100644 --- a/tests/textual/test_sidebar_destinations.py +++ b/tests/textual/test_sidebar_destinations.py @@ -6,7 +6,8 @@ from textual.app import App, ComposeResult from textual.screen import Screen -from toad.navigation_target import NavigationContext, NavigationOwner, NavigationTarget +from toad.navigation_context import NavigationContext, NavigationOwner +from toad.navigation_target import NavigationTarget from toad.textual_colours import ThemedApp from toad.widgets.comms_sidebar import CommsRow diff --git a/tests/textual/test_tab_underline.py b/tests/textual/test_tab_underline.py index dcb9b4cdb..e65551182 100644 --- a/tests/textual/test_tab_underline.py +++ b/tests/textual/test_tab_underline.py @@ -2,7 +2,8 @@ import pytest -from toad.navigation_target import DirectTarget, NavigationContext, channel_target +from toad.navigation_context import NavigationContext +from toad.navigation_target import DirectTarget, channel_target from toad.widgets.session_tabs import SessionLabel, SessionsTabs, Underline From 42202b412ec0b099f89b130e87fe0e7dda068312 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 06:26:50 -0400 Subject: [PATCH 06/43] P401: verify navigation in the real sidebar and type its workspace owner --- src/toad/navigation_target.py | 1 - src/toad/screens/workspace.py | 8 +++ tests/textual/test_sidebar_destinations.py | 63 +++++++++------------- 3 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 269bf0f95..1c9aa71d9 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -12,7 +12,6 @@ from toad.constants import ALL_COMMS_TARGET if TYPE_CHECKING: - from toad.app import ToadApp from toad.navigation_context import NavigationContext, NavigationOwner diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index 6584cd8b2..e2b320a16 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -1,5 +1,9 @@ """The one native workspace frame; session surfaces never own compositors.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from dataclasses import dataclass, replace from functools import cached_property @@ -14,6 +18,9 @@ from agent_comms.declared_family import DeclaredFamily from abc import abstractmethod +if TYPE_CHECKING: + from toad.app import ToadApp + @dataclass(frozen=True) class WorkspaceLayoutSnapshot: """One native layout's size, style and invalidation proof.""" @@ -60,6 +67,7 @@ def reusable(self, screen, size, *, include_scroll=True): class WorkspaceScreen(WidgetView, Screen): + app: ToadApp layer_order = (DEFAULT_LAYER, "comms-sidebar", "_tooltips", "_toastrack") BINDINGS = [ diff --git a/tests/textual/test_sidebar_destinations.py b/tests/textual/test_sidebar_destinations.py index 689aada49..2cbf02b11 100644 --- a/tests/textual/test_sidebar_destinations.py +++ b/tests/textual/test_sidebar_destinations.py @@ -1,44 +1,33 @@ -"""Textual-facing: a navigation destination declared as a target class is opened by the mounted sidebar row without a dispatcher.""" +"""A declared destination opens through the real app's mounted sidebar row.""" -import asyncio -from pathlib import Path - -from textual.app import App, ComposeResult -from textual.screen import Screen - -from toad.navigation_context import NavigationContext, NavigationOwner from toad.navigation_target import NavigationTarget -from toad.textual_colours import ThemedApp -from toad.widgets.comms_sidebar import CommsRow +from toad.widgets.comms_sidebar import CommsRow, CommsSidebar -async def test_new_destination_uses_existing_row(): - selected = asyncio.Event() +async def test_new_destination_uses_existing_row(workspace): + opened = [] class TestReportTarget(NavigationTarget): async def open(self, context): - context.app.selected_destination = self.name - selected.set() - return context.app.current_mode - - class DestinationScreen(NavigationOwner, Screen): - @property - def navigation_context(self): - return NavigationContext(self.app, self.app.current_mode, Path.cwd(), "viewer") - - def compose(self) -> ComposeResult: - yield CommsRow(TestReportTarget("report"), "Open report") - - class DestinationApp(ThemedApp, App): - @property - def selected_session(self): - return self.screen - - def get_default_screen(self): - return DestinationScreen() - - app = DestinationApp() - async with app.run_test() as pilot: - await pilot.click(CommsRow) - await asyncio.wait_for(selected.wait(), 2) - assert app.selected_destination == "report" + opened.append(context) + return context.owner_mode + + workspace.thread("row-peer", "team") + async with workspace.app() as ui: + app, pilot = ui.textual + sidebar = app.workspace_chrome.channels + if sidebar.collapsed: + sidebar.toggle() + tree = sidebar.query_one(CommsSidebar) + await ui.until(lambda: "#team" in tree.groups, why="the real sidebar's channel group") + group = tree.groups["#team"] + if not group.expanded: + group.toggle_members() + row = CommsRow(TestReportTarget("report"), "Open report") + await group.member_container.mount(row) + row.scroll_visible(animate=False) + await ui.step() + context = app.selected_session.navigation_context + assert await pilot.click(row) + await ui.until(lambda: bool(opened), why="the row opening its declared destination") + assert opened == [context] From c4ef839da6c10df42a363fe2af6cab7810efb418 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 07:34:14 -0400 Subject: [PATCH 07/43] P401: carry typed destinations and ask the cursor owner about menus --- src/toad/block_navigation.py | 6 +++ src/toad/navigation_target.py | 44 ++++++++++++++++++++++ src/toad/screens/comms.py | 5 ++- src/toad/screens/main.py | 6 +-- src/toad/screens/session_view.py | 5 +++ src/toad/session_scope.py | 6 +-- src/toad/widgets/conversation.py | 3 +- src/toad/widgets/sidebar_tree.py | 6 +-- src/toad/workspace_sessions.py | 9 +++-- tests/textual/test_block_menu_and_copy.py | 2 + tests/textual/test_conversation_value.py | 1 + tests/textual/test_sidebar_destinations.py | 3 ++ 12 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index e9408965f..2077a4323 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -343,6 +343,12 @@ def blocks(self): def current(self) -> ConversationEntry | None: return self._current if self._current in self.blocks else None + def accepts_menu_owner(self, owner: Widget) -> bool: + for block in self.blocks: + if block is self._current: + return block.block_cursor.accepts(owner) + return False + def clear(self) -> None: self._current = None diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 277a40b4b..9f385a647 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -40,11 +40,41 @@ def menu_context(self, sidebar, *, channel: ChannelSelection, mode_name=None): def selected(self, owner: NavigationOwner) -> None: """Apply view-specific selection intent before beginning navigation.""" + def matches(self, target: NavigationTarget) -> bool: + """Different opening behaviors may display the same sidebar address.""" + return self.name == target.name + + def rows_in(self, group): + return group.rows_for_target(self.name) + @abstractmethod async def open(self, context: NavigationContext) -> str: pass +@dataclass(frozen=True) +class NoTarget(NavigationTarget): + name: str = field(default="", init=False) + + def menu_context(self, sidebar, **kwargs): + pass + + def show_menu(self, sidebar, offset, **kwargs) -> None: + pass + + def selected(self, owner: NavigationOwner) -> None: + pass + + def matches(self, target: NavigationTarget) -> bool: + return False + + def rows_in(self, group): + return () + + async def open(self, context: NavigationContext) -> str: + return context.owner_mode + + class SessionTarget(NavigationTarget): async def open(self, context: NavigationContext) -> str: app = context.app @@ -69,6 +99,16 @@ class HistoryRoute: def history_kind(self) -> type[ConversationKind]: ... class HistoryTarget(HistoryRoute, NavigationTarget): + @classmethod + def for_name(cls, name: str): + return cls(name) + + @classmethod + def for_conversation(cls, conversation: ConversationKind) -> HistoryTarget: + target, = (member for member in cls.members_with(HistoryRoute) + if member.history_kind is type(conversation)) + return target.for_name(conversation.target.name) + async def open(self, context: NavigationContext) -> str: return await context.app.session_navigation.history( owner_mode=context.owner_mode, project_path=context.project_path, @@ -92,6 +132,10 @@ class FeedTarget(ChannelLike, HistoryTarget): history_kind = IrcConversation expanded_by_default = True + @classmethod + def for_name(cls, name: str): + return cls() + class ChannelTarget(ChannelLike, HistoryTarget): history_kind = ChannelConversation diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 1a305f4cf..df4d9f625 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -24,6 +24,7 @@ from toad.session_tracker import SidebarState from toad.widgets.side_bar import SideBar from toad.navigation_context import NavigationContext +from toad.navigation_target import HistoryTarget, NavigationTarget from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import SideBar, ThreadSidebar, TabHistoryControls from toad.widgets.thread_comms import RelationshipSort, ThreadCommsSidebar @@ -87,8 +88,8 @@ def origin(self): return self.app.workspace_sessions.origin(self.owner_mode) @property - def navigation_target_name(self) -> str: - return self.conversation.target.name + def navigation_target(self) -> NavigationTarget: + return HistoryTarget.for_conversation(self.conversation) def rebind_identity(self, identity: str) -> None: with self.changing_identity(): diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index a9a688138..b9f907baf 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -53,7 +53,7 @@ from toad.widgets.project_panel import ProjectPanel, ProjectSearchButton from toad.widgets.side_bar import SideBar, SideBarCollapsible from toad.widgets.channels_sidebar import ChannelsSidebar -from toad.navigation_target import FeedTarget, DirectTarget +from toad.navigation_target import FeedTarget, DirectTarget, NavigationTarget, ThreadTarget class SessionCommands(Provider): @@ -432,8 +432,8 @@ async def on_session_attached(self, message: CoreEventMessage) -> None: _last_dm_target: str | None = None @property - def navigation_target_name(self) -> str: - return self.thread_identity.thread_name() + def navigation_target(self) -> NavigationTarget: + return ThreadTarget(self.thread_identity.thread_name()) @property def navigation_context(self) -> NavigationContext: diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 8067a27f1..214f982e5 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -9,6 +9,7 @@ from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.thread_identity import SessionIdentity, NoThreadIdentity from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope +from toad.navigation_target import NavigationTarget, NoTarget from toad.widgets.message_notifications import NothingToRead, PaintedRead, VisibleReadMarking class SessionView(VisibleReadMarking, CoreEventReceiver, Vertical, SessionScope): @@ -43,6 +44,10 @@ def origin(self) -> SessionOrigin: """What a session or history requested from this view starts from.""" return NoSessionOrigin(str(self)) + @property + def navigation_target(self) -> NavigationTarget: + return NoTarget() + def belongs_to_wire(self, root) -> bool: """Unbound views may project the selected wire; bound views stay on it.""" from pathlib import Path diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index be7a7c43c..0b14951eb 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -19,6 +19,7 @@ from toad.agent_schema import AgentDefinition from toad.app import ToadApp + from toad.navigation_target import NavigationTarget from toad.session_presentation import OperationalSessionPresentation from toad.thread_identity import SessionIdentity @@ -75,9 +76,8 @@ def is_current(self) -> bool: ... def display_name(self) -> str: ... @property - def navigation_target_name(self) -> str | None: - """Views without a displayed destination have no navigation name.""" - return None + @abstractmethod + def navigation_target(self) -> NavigationTarget: ... @property @abstractmethod diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index b2515ed8d..eafcaf582 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1252,9 +1252,8 @@ 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: event.stop() - cursor = self.navigation.cursor if (self.query_ancestor(SessionScope).is_current and event.action is not None - and cursor is not None and cursor.accepts(event.owner)): + and self.navigation.accepts_menu_owner(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) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index d9179815c..686ffe72a 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -801,16 +801,16 @@ def navigation_root(self): raise NotImplementedError def row_current(self, row) -> bool: - return row.target_name == self.app.workspace_sessions.visible_target(self.navigation_root) + return self.app.workspace_sessions.visible_target(self.navigation_root).matches(row.target) def rows_for_target(self, target): - return tuple(row for group in self.navigation_groups for row in group.rows_for_target(target)) if target is not None else () + return tuple(row for group in self.navigation_groups for row in target.rows_in(group)) @property def current_rows(self): target = self.app.workspace_sessions.visible_target(self.navigation_root) return tuple(row for group in self.navigation_groups if group.accepts_navigation() - for row in group.rows_for_target(target) if row is group.row or group.members_visible) + for row in target.rows_in(group) if row is group.row or group.members_visible) @handles(SessionSelected) def session_selected(self, event) -> None: diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index f2a25dd70..4e45cc340 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -8,6 +8,7 @@ from toad.constants import WELCOME from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope +from toad.navigation_target import NavigationTarget, NoTarget if TYPE_CHECKING: from toad.app import ToadApp @@ -197,15 +198,15 @@ async def retire(self) -> None: def owns(self, view: "SessionScope") -> bool: return self.source.current(view) - def visible_target(self, root): + def visible_target(self, root) -> NavigationTarget: """The selected logical destination, including while a modal covers its frame.""" view = self.source.view - return self.target_for_mode(view.id, root) if view is not None and self.owns(view) else None + return self.target_for_mode(view.id, root) if view is not None and self.owns(view) else NoTarget() - def target_for_mode(self, identity, root): + def target_for_mode(self, identity, root) -> NavigationTarget: """Resolve a transition's destination from its original workspace view.""" view = self.views.get(identity) - return view.navigation_target_name if root is not None and view is not None and view.belongs_to_wire(root) else None + return view.navigation_target if root is not None and view is not None and view.belongs_to_wire(root) else NoTarget() def release_frame_callback(self, owner, callback) -> bool: """Admit mounted work through its original logical source binding. diff --git a/tests/textual/test_block_menu_and_copy.py b/tests/textual/test_block_menu_and_copy.py index 244ead917..7b21b9c0a 100644 --- a/tests/textual/test_block_menu_and_copy.py +++ b/tests/textual/test_block_menu_and_copy.py @@ -62,10 +62,12 @@ async def test_block_menu_copy_prompt_and_expansion(workspace, private_display): tool.set_expanded(False) await view.contents.mount(user, extra, tool) await ui.step() + assert not view.navigation.accepts_menu_owner(extra) async def choose(widget, key): assert isinstance(widget, BlockContent), type(widget) assert view.navigation.select(widget) + assert view.navigation.accepts_menu_owner(widget) view.refresh_block_cursor(lambda cursor: cursor.show_moved_to(view.window)) await ui.press("enter") await ui.until(lambda: bool(view.query(Menu)), why="the block menu") diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 847f17012..17cbd29e1 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -29,6 +29,7 @@ async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and ): mode = await target.open(context) screen = app.selected_session + assert screen.navigation_target == target await screen.wait_content_ready() chat = screen.query_one(CommsChatView) await ui.until(lambda: body in ui.painted(chat.window), diff --git a/tests/textual/test_sidebar_destinations.py b/tests/textual/test_sidebar_destinations.py index 2cbf02b11..b2aaaa221 100644 --- a/tests/textual/test_sidebar_destinations.py +++ b/tests/textual/test_sidebar_destinations.py @@ -21,6 +21,9 @@ async def open(self, context): tree = sidebar.query_one(CommsSidebar) await ui.until(lambda: "#team" in tree.groups, why="the real sidebar's channel group") group = tree.groups["#team"] + absent = app.workspace_sessions.visible_target(None) + assert not absent.matches(app.selected_session.navigation_target) + assert absent.rows_in(group) == () if not group.expanded: group.toggle_members() row = CommsRow(TestReportTarget("report"), "Open report") From 4b999502230f8cdb0405f6e3e9650ff050089ccf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 07:43:09 -0400 Subject: [PATCH 08/43] P401: let transcript scopes own every reader and absent operation --- src/toad/acp/transcript_reader.py | 19 ++-- src/toad/comms_root.py | 10 -- src/toad/conversation_kind.py | 2 +- src/toad/session_entry.py | 3 +- src/toad/session_presentation.py | 20 +--- src/toad/session_scope.py | 113 ++++++++++++++++++- src/toad/status_thread.py | 94 +++++++++++++++ src/toad/thread_identity.py | 9 +- src/toad/transcript_preparation.py | 3 +- src/toad/transcript_publication.py | 32 +++--- src/toad/transcript_state.py | 4 +- src/toad/widgets/channel_activity.py | 2 +- src/toad/widgets/comms_chat.py | 8 +- src/toad/widgets/conversation.py | 13 +-- src/toad/widgets/observed_thread_activity.py | 86 +------------- tests/app/test_session_scope.py | 4 +- tests/model/test_conversation_kind.py | 2 +- tests/textual/test_block_menu_and_copy.py | 5 +- 18 files changed, 259 insertions(+), 170 deletions(-) create mode 100644 src/toad/status_thread.py diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index 79a32dc35..00630c7f6 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -18,10 +18,12 @@ from agent_comms.route_selection import RouteSelection from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate from agent_comms.ui_model.observation import ( - AwaitedRead, ReadTranscript, ReadTranscriptUpdate, RefreshTranscript, + AwaitedRead, RefreshTranscript, TranscriptPublicationState, ) +from toad.session_scope import TranscriptScope + class CoordinationTranscriptReader: """The application's route-owned reads, shared by agents from construction.""" @@ -54,19 +56,18 @@ async def publication(self, update: TranscriptSnapshotUpdate) -> TranscriptSnaps current = await self.read(RefreshTranscript(identity.root, identity)) return update if current is None else current - async def page(self, root, thread, *, before=None, after=None, through=None, + async def page(self, scope: TranscriptScope, *, before=None, after=None, through=None, historical_source: str | None = None): - snapshot = await self.snapshot(root, thread, before=before, after=after, through=through, + snapshot = await self.snapshot(scope, before=before, after=after, through=through, historical_source=historical_source) return snapshot.page - async def snapshot(self, root, thread, *, before=None, after=None, through=None, + async def snapshot(self, scope: TranscriptScope, *, before=None, after=None, through=None, historical_source: str | None = None) -> TranscriptSnapshotUpdate: - return await self.read(ReadTranscript( - root, thread, before=before, after=after, through=through, - historical_source=historical_source)) + return await self.read(scope.snapshot_request( + before=before, after=after, through=through, historical_source=historical_source)) async def updates( - self, root: str, thread: str, previous: TranscriptPublicationState, *, after=None, + self, scope: TranscriptScope, previous: TranscriptPublicationState, *, after=None, ) -> TranscriptSnapshotUpdate | TranscriptDraftUpdate: - return await self.read(ReadTranscriptUpdate(root, thread, after=after, previous=previous)) + return await self.read(scope.update_request(previous, after=after)) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 78eceeb4c..0c3fc0ec2 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -35,7 +35,6 @@ from agent_comms.ui_model.sidebar import SidebarModel, SidebarObservation from agent_comms.ui_model.status import OpenThreadStatus from toad.core.events import CoreEventStream, CoordinationObserved, ExternalTranscriptChanged, OpenTabsChanged, LinkContextChanged, ObservedServiceChanged, ThreadRowsChanged -from toad.core_event_carrier import ThreadCallbackSubscription from toad.resource_release import ResourceRelease from toad.preferences import SETTINGS from agent_comms.display_references import PendingReferences, UnavailableReferences @@ -455,15 +454,6 @@ def sidebar_request(self) -> ObserveSidebar: SETTINGS.sidebar.show_archived.bind(state).value, ) - def observe_thread_changes(self, view, scope: tuple[str, str], resume) -> ResourceRelease: - """Hold the shared model while this native read waits for its own thread.""" - release = ResourceRelease() - - release.callback(view.retire_core, view.observe_core_callback( - self.events, ThreadRowsChanged, resume, - factory=partial(ThreadCallbackSubscription, scope=scope))) - return release - def update_sidebar_interest(self) -> None: wanted = self.sidebar_request() if self.sidebar_views or self.reference_views or self.sidebar_waiters else None if wanted != self.sidebar_interest: diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index f101d3408..ac9ae202d 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -14,7 +14,7 @@ from toad.widgets.native_history import NativeHistory from toad.widgets.session_details import ConversationSessionDetails from toad.widgets.channel_activity import ChannelActivity -from toad.widgets.observed_thread_activity import NoStatusThread, StatusThread +from toad.status_thread import NoStatusThread, StatusThread from agent_comms.channel_targets import ChannelTarget, NamedTarget, ThreadTarget from agent_comms.channels import AllChannel from toad.widgets.sidebar_tree import UnpaintedChannel diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index 9dc2ee44b..e0c99b381 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -103,8 +103,7 @@ def original_threads(self, sessions): source = self.source(sessions) if source is None: return (self.original,) if self.original is not None else () - scope = source.thread_identity.transcript_scope() - return ((scope[0], source.thread_identity.thread_incarnation()),) if scope is not None else () + return source.thread_identity.transcript_scope().original_threads(source.thread_identity) def __call__(self) -> MainScreen: source = self.factory() diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 1133b8e25..2dc84a5d1 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -13,7 +13,7 @@ from textual.widgets.text_area import TextAreaState from agent_comms.acp_extension import CoordinationChangedUpdate -from agent_comms.channel_targets import ThreadTarget +from toad.conversation_kind import DmConversation from agent_comms.session_load import EnsuringSessionLoadPlan from toad.owner_preparation import ExistingOwnerPlan @@ -233,10 +233,7 @@ def agent_info(self, conversation: Conversation) -> str: return conversation.thread_activity.row.presentation.title def prompt(self, conversation: Conversation): - from toad.conversation_kind import DmConversation - - root, name = conversation.transcript_scope() - return DmConversation(ThreadTarget(name)).prompt() + return DmConversation(conversation.transcript_scope().mailbox_target()).prompt() def input_ready(self, conversation: Conversation) -> bool: return conversation.transcript.readable @@ -246,24 +243,15 @@ async def present(self, conversation: Conversation) -> None: self.external_transcript_changed(conversation) async def submit(self, owner, event): - from toad.conversation_kind import DmConversation - view = owner.view - scope = view.transcript.binding.thread_scope - if scope is None: - view.delivery_refused("CLI transcript is not ready", event.body) - return - root, target = scope async def presented(sent): view.run_worker(view.input_histories.prompt.record(event.body), group="history") view.window.jump_to_latest() self.external_transcript_changed(view) - await view.submit_comms_message( - root, DmConversation(ThreadTarget(target)), event.body, - lambda: view.is_attached and view.transcript.binding.thread_scope == scope, - presented, + await view.transcript.binding.thread_scope.submit_mailbox( + view, event, DmConversation, presented, ) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 0b14951eb..9378749b5 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -2,17 +2,26 @@ from __future__ import annotations -from abc import abstractmethod +from abc import ABC, abstractmethod from collections.abc import Awaitable +from dataclasses import dataclass +from functools import partial from pathlib import Path from typing import TYPE_CHECKING, ClassVar, Never from agent_comms.errors import RelationViolationError from agent_comms.route_selection import RouteSelection +from agent_comms.channel_targets import ThreadTarget +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.ui_model.observation import ReadTranscript, ReadTranscriptUpdate from textual.dom import DOMNode from toad.navigation_context import NavigationOwner from toad.project_path_owner import ProjectPathOwner +from toad.core.events import ThreadRowsChanged +from toad.core_event_carrier import ThreadCallbackSubscription +from toad.resource_release import ResourceRelease +from toad.status_thread import NoStatusThread, StatusThread if TYPE_CHECKING: from textual.command import Provider @@ -24,11 +33,105 @@ from toad.thread_identity import SessionIdentity +class TranscriptScope(ABC): + """A transcript address owns its reads, observations and absent behavior.""" + + @property + @abstractmethod + def readable(self) -> bool: ... + + @abstractmethod + def original_threads(self, identity: SessionIdentity) -> tuple[tuple[str, ThreadIncarnation], ...]: ... + + @abstractmethod + def status_thread(self, access) -> StatusThread: ... + + @abstractmethod + def observe(self, view, access, resume) -> ResourceRelease: ... + + @abstractmethod + def snapshot_request(self, **bounds) -> ReadTranscript: ... + + @abstractmethod + def update_request(self, previous, **bounds) -> ReadTranscriptUpdate: ... + + @abstractmethod + def mailbox_target(self) -> ThreadTarget: ... + + @abstractmethod + async def submit_mailbox(self, view, event, conversation_type, presented) -> None: ... + + +@dataclass(frozen=True) +class KnownTranscript(TranscriptScope): + root: str + name: str + readable = True + + def original_threads(self, identity: SessionIdentity) -> tuple[tuple[str, ThreadIncarnation], ...]: + return ((self.root, identity.thread_incarnation()),) + + def status_thread(self, access) -> StatusThread: + return StatusThread.for_root(Path(self.root), self.name, access) + + def observe(self, view, access, resume) -> ResourceRelease: + release = ResourceRelease() + release.callback(view.retire_core, view.observe_core_callback( + access.events, ThreadRowsChanged, resume, + factory=partial(ThreadCallbackSubscription, scope=(self.root, self.name)))) + return release + + def snapshot_request(self, **bounds) -> ReadTranscript: + return ReadTranscript(self.root, self.name, **bounds) + + def update_request(self, previous, **bounds) -> ReadTranscriptUpdate: + return ReadTranscriptUpdate(self.root, self.name, previous=previous, **bounds) + + def mailbox_target(self) -> ThreadTarget: + return ThreadTarget(self.name) + + async def submit_mailbox(self, view, event, conversation_type, presented) -> None: + await view.submit_comms_message( + self.root, conversation_type(self.mailbox_target()), event.body, + lambda: view.is_attached and view.transcript.binding.thread_scope == self, + presented, + ) + + +@dataclass(frozen=True) +class NoTranscript(TranscriptScope): + readable = False + + def original_threads(self, identity: SessionIdentity) -> tuple[tuple[str, ThreadIncarnation], ...]: + return () + + def status_thread(self, access) -> StatusThread: + return NoStatusThread() + + def observe(self, view, access, resume) -> ResourceRelease: + return ResourceRelease() + + def unavailable(self) -> Never: + raise ValueError("Transcript paging requires a bound thread") + + def snapshot_request(self, **bounds) -> Never: + self.unavailable() + + def update_request(self, previous, **bounds) -> Never: + self.unavailable() + + def mailbox_target(self) -> Never: + self.unavailable() + + async def submit_mailbox(self, view, event, conversation_type, presented) -> None: + view.delivery_refused("CLI transcript is not ready", event.body) + + class SessionOrigin: """An origin answers for itself, including when its view is unavailable.""" @abstractmethod - def transcript_scope(self) -> tuple[str, str] | None: ... + def transcript_scope(self) -> TranscriptScope: ... @abstractmethod def has_agent(self) -> bool: ... @@ -44,8 +147,8 @@ class NoSessionOrigin(SessionOrigin): def __init__(self, view: str = "unavailable view") -> None: self.view = view - def transcript_scope(self) -> None: - return None + def transcript_scope(self) -> NoTranscript: + return NoTranscript() def has_agent(self) -> bool: return False @@ -124,7 +227,7 @@ class ViewSessionOrigin(SessionOrigin, SessionScope): def origin(self) -> SessionOrigin: return self - def transcript_scope(self) -> tuple[str, str] | None: + def transcript_scope(self) -> TranscriptScope: return self.thread_identity.transcript_scope() def require(self) -> ViewSessionOrigin: diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py new file mode 100644 index 000000000..5d30ef481 --- /dev/null +++ b/src/toad/status_thread.py @@ -0,0 +1,94 @@ +"""Thread status sources, independent of their mounted activity view.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING + +from agent_comms.coordination_errors import CoordinationReadUnavailable +from agent_comms.ui_model.observation import UnreadableHistory +from agent_comms.ui_model.status import ThreadStatusModel + +from toad.core.events import ThreadRowsChanged +from toad.core_event_carrier import ThreadCallbackSubscription + +if TYPE_CHECKING: + from toad.widgets.observed_thread_activity import ObservedThreadActivity, ThreadStatusOwner + + +class StatusThread(ABC): + """The status source selected by a view, independent of ACP turn permissions.""" + + name: str + + @classmethod + def for_root(cls, root: Path, name: str, access) -> StatusThread: + if access.observation.observed().observes(root): + return LocalStatusThread(root, name) + return ForeignStatusThread(root, name) + + @abstractmethod + def bind(self, activity: ObservedThreadActivity) -> None: ... + + def refresh(self, view: ThreadStatusOwner) -> None: + view.thread_activity.bind(self) + + def observe(self, activity: ObservedThreadActivity) -> None: + """Only a local source acquires a scoped observation subscription.""" + + +@dataclass(frozen=True) +class NoStatusThread(StatusThread): + name = "" + + def bind(self, activity: ObservedThreadActivity) -> None: + activity.show(ThreadStatusModel.pending(self.name)) + + def refresh(self, view: ThreadStatusOwner) -> None: + """A conversation without a status source has no row to refresh.""" + + +@dataclass(frozen=True) +class LocalStatusThread(StatusThread): + root: Path + name: str + + def bind(self, activity: ObservedThreadActivity) -> None: + activity.show(activity.app.coordination_access.thread_status.open(self.name)) + + def observe(self, activity: ObservedThreadActivity) -> None: + access = activity.app.coordination_access + activity.observe_core_callback( + access.events, ThreadRowsChanged, activity.status_changed, + factory=partial(ThreadCallbackSubscription, scope=(str(self.root), self.name))) + + +@dataclass(frozen=True) +class ForeignStatusThread(StatusThread): + root: Path + name: str + + def bind(self, activity: ObservedThreadActivity) -> None: + activity.show(ThreadStatusModel.pending(self.name)) + + def refresh(self, view: ThreadStatusOwner) -> None: + super().refresh(view) + view.run_worker(self.read_foreign_thread(view), group="foreign-thread-status", exclusive=True) + + async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: + try: + presentation = await view.app.coordination_access.transcripts.thread_presentation( + str(self.root), self.name) + row = ThreadStatusModel.known(self.name, presentation) + except CoordinationReadUnavailable: + return + except (OSError, ValueError, RuntimeError) as error: + presentation = None + row = ThreadStatusModel.unreadable(self.name, UnreadableHistory.capture(error)) + if view.status_thread() != self: + return + view.thread_activity.show(row) + view.foreign_thread_read(presentation) diff --git a/src/toad/thread_identity.py b/src/toad/thread_identity.py index e11f681c5..ae077d7be 100644 --- a/src/toad/thread_identity.py +++ b/src/toad/thread_identity.py @@ -5,6 +5,7 @@ from pathlib import Path from toad.format_path import format_path from toad.transcript_state import UnboundTranscript, UnreadTranscript +from toad.session_scope import KnownTranscript, NoTranscript, TranscriptScope from agent_comms.errors import UnregisteredThreadError from agent_comms.thread_identity import ThreadIncarnation, UnrecordedThreadIncarnation @@ -35,8 +36,8 @@ def with_session(self, session_id: str): def wire_root(self) -> str | None: return None - def transcript_scope(self) -> tuple[str, str] | None: - return None + def transcript_scope(self) -> TranscriptScope: + return NoTranscript() def transcript_state(self): return UnboundTranscript() @@ -137,8 +138,8 @@ def bind_history(self, histories) -> None: def wire_root(self) -> str: return self.root - def transcript_scope(self) -> tuple[str, str]: - return self.root, self.thread_name() + def transcript_scope(self) -> KnownTranscript: + return KnownTranscript(self.root, self.thread_name()) def transcript_state(self): return UnreadTranscript() diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 79b37edc5..06c910744 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field, replace from typing import ClassVar, TYPE_CHECKING from weakref import ReferenceType, ref +from toad.status_thread import NoStatusThread from agent_comms.transcripts import TranscriptCursor, TranscriptPage, UnreadTranscriptCursor from agent_comms.transcript_events import TranscriptEvent @@ -560,8 +561,6 @@ async def route_is_current(self) -> bool: return False def status_thread(self, access): - from toad.widgets.observed_thread_activity import NoStatusThread - return NoStatusThread() def command_context(self, view): diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index a418e6355..fb8ef6ed3 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -27,7 +27,7 @@ from toad.widgets.session_details import SessionDetails from toad.transcript_preparation import PreparedPageSource from toad.core.events import CoreEventStream -from toad.session_scope import SessionOrigin, SessionScope +from toad.session_scope import NoTranscript, SessionOrigin, SessionScope, TranscriptScope if TYPE_CHECKING: from toad.widgets.conversation import Conversation @@ -161,7 +161,7 @@ class CanonicalSourcePublication(MroDispatch, TranscriptPublication): async def read_snapshot(self): return await self.read_past_history( - partial(self.binding.reader.updates, *self.thread_scope, self.owner.current_read)) + partial(self.binding.reader.updates, self.thread_scope, self.owner.current_read)) async def read_past_history(self, read, **whole): """What follows the mounted history's end while its source only grew, else the whole latest page.""" @@ -265,8 +265,8 @@ def defer(self, publication: TranscriptPublication) -> None: self.pending.append(publication) view = publication.binding.view self.observation.close() - self.observation.enter_context(view.app.coordination_access.observe_thread_changes( - view, publication.thread_scope, self.resume)) + self.observation.enter_context(publication.thread_scope.observe( + view, view.app.coordination_access, self.resume)) def resume(self, _event=None) -> bool: """Resume kept work only at an existing source/preparation signal.""" @@ -302,7 +302,7 @@ def __init__(self, binding, owner, generation): async def read_snapshot(self): # Mounted history grows by what follows its end; without one the window needs a whole page. - return await self.read_past_history(partial(self.binding.reader.snapshot, *self.thread_scope)) + return await self.read_past_history(partial(self.binding.reader.snapshot, self.thread_scope)) def current(self) -> bool: return super().current() and self.plan.current(self.binding.window) @@ -384,8 +384,8 @@ def bound_to(self, view): return view.transcript_binding() @property - def thread_scope(self): - return None + def thread_scope(self) -> TranscriptScope: + return NoTranscript() reports_coverage = False @@ -483,9 +483,9 @@ def bound_to(self, view): return self @property - def thread_scope(self): + def thread_scope(self) -> TranscriptScope: view = self.view - return view.transcript_scope() if view.is_attached else None + return view.transcript_scope() if view.is_attached else NoTranscript() @property def reader(self): @@ -505,11 +505,9 @@ def history(self) -> TranscriptHistory: return history def observe_thread(self, owner): - scope = self.thread_scope - if scope is not None: - view = self.view - owner._thread_observation.enter_context( - view.app.coordination_access.observe_thread_changes(view, scope, view.thread_observed)) + view = self.view + owner._thread_observation.enter_context( + self.thread_scope.observe(view, view.app.coordination_access, view.thread_observed)) @property def reports_coverage(self): @@ -667,14 +665,14 @@ def _unbind_view(self) -> None: @property def readable(self) -> bool: - return self.binding.thread_scope is not None + return self.binding.thread_scope.readable async def get_transcript_page(self, *, before=None, after=None, through=None) -> TranscriptPage: frame = self.binding.capture(self, BoundFrame) scope = self.binding.thread_scope - if frame is None or scope is None: + if frame is None: raise ValueError("Transcript paging requires a bound thread") - page = await frame.binding.reader.page(*scope, before=before, after=after, through=through) + page = await frame.binding.reader.page(scope, before=before, after=after, through=through) if self.binding.thread_scope != scope: raise ValueError("Thread source changed while reading transcript") return page diff --git a/src/toad/transcript_state.py b/src/toad/transcript_state.py index 8c057e998..3b047137e 100644 --- a/src/toad/transcript_state.py +++ b/src/toad/transcript_state.py @@ -227,9 +227,7 @@ def observe(self): from toad.widgets.conversation import Conversation scope = self.owner.query_ancestor(Conversation).transcript.binding.thread_scope - with self.owner.app.coordination_access.observe_thread_changes( - self.owner, scope, self.resume - ): + with scope.observe(self.owner, self.owner.app.coordination_access, self.resume): yield self def resume(self, _event) -> None: diff --git a/src/toad/widgets/channel_activity.py b/src/toad/widgets/channel_activity.py index 163ee296f..fce2d07ff 100644 --- a/src/toad/widgets/channel_activity.py +++ b/src/toad/widgets/channel_activity.py @@ -6,7 +6,7 @@ from textual.containers import VerticalGroup from toad.session_scope import SessionScope -from toad.widgets.observed_thread_activity import StatusThread +from toad.status_thread import StatusThread from toad.widgets.sidebar_tree import KeyedRows, SidebarChange, SidebarModelView diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 4a83cb27b..dd7484846 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -33,10 +33,10 @@ CursorContainer, Window, ) -from toad.widgets.observed_thread_activity import StatusThread +from toad.status_thread import StatusThread from toad.widgets.flash import Flash from toad.widgets.prompt import Prompt -from toad.session_scope import SessionScope +from toad.session_scope import NoTranscript, SessionScope from toad.command_catalog import CommandCatalog @@ -198,9 +198,9 @@ def watch_agent_binding(self, agent) -> None: """Keep the inherited agent reactive from turning this into a shell session.""" self.agent_info = Content(self.conversation.label()) - def transcript_scope(self) -> None: + def transcript_scope(self) -> NoTranscript: """DM/channel history belongs to WireHistory, not a native transcript.""" - return None + return NoTranscript() def status_thread(self) -> StatusThread: """Peer status does not grant access to the peer's full transcript.""" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index eafcaf582..c430a95c7 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -2,7 +2,7 @@ from toad.core import input_events from toad.core_event_carrier import CoreEventMessage from toad.widget_view import WorkspaceHosted -from toad.session_scope import SessionScope +from toad.session_scope import SessionScope, TranscriptScope from toad.conversation_submission import ConversationSubmissions from toad.live_output import LiveOutput, ThoughtStream @@ -77,9 +77,10 @@ from toad.widgets.goal_bar import GoalBar from toad.widgets.native_history import NativeHistory from toad.widgets.observed_thread_activity import ( - NoStatusThread, ObservedThreadActivity, StatusThread, ThreadStatusOwner, + ObservedThreadActivity, ThreadStatusOwner, ) from toad.widgets.cli_draft import CliDraft +from toad.status_thread import StatusThread from toad.widgets.transcript_history import JumpToLatest, TranscriptHistory from toad.widgets.session_details import OperationalSessionDetails, SessionDetails from toad.private_native_cursor import CursorStatus, HiddenCursorStatus @@ -354,16 +355,12 @@ class ConversationSessionBinding(WorkspaceHosted, ThreadStatusOwner, containers. def operational_sources(self): return self.query_ancestor(SessionScope).presentation.sources - def transcript_scope(self) -> tuple[str, str] | None: + def transcript_scope(self) -> TranscriptScope: """The native session identity whose canonical transcript this view shows.""" return self.query_ancestor(SessionScope).origin.transcript_scope() def status_thread(self) -> StatusThread: - scope = self.transcript.binding.thread_scope - if scope is None: - return NoStatusThread() - root, name = scope - return StatusThread.for_root(Path(root), name, self.app.coordination_access) + return self.transcript.binding.thread_scope.status_thread(self.app.coordination_access) def arm_thread_read(self) -> None: """Capture turn token and client authority for the next observed presentation.""" diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index 685954d5f..a85c243fc 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -2,97 +2,15 @@ from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass -from pathlib import Path -from functools import partial - -from agent_comms.coordination_errors import CoordinationReadUnavailable from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.status import ThreadStatusModel -from agent_comms.ui_model.observation import UnreadableHistory from textual.message import Message from textual.reactive import var from textual.widget import Widget from toad.core.events import ThreadRowsChanged -from toad.core_event_carrier import CoreEventReceiver, ThreadCallbackSubscription - - -class StatusThread(ABC): - """The status source selected by a view, independent of ACP turn permissions.""" - - name: str - - @classmethod - def for_root(cls, root: Path, name: str, access) -> StatusThread: - """Both the session binding and Comms service supply absolute wire roots.""" - if access.observation.observed().observes(root): - return LocalStatusThread(root, name) - return ForeignStatusThread(root, name) - - @abstractmethod - def bind(self, activity: ObservedThreadActivity) -> None: ... - - def refresh(self, view: ThreadStatusOwner) -> None: - view.thread_activity.bind(self) - - def observe(self, activity: ObservedThreadActivity) -> None: - """Only a local source acquires a scoped observation subscription.""" - - -@dataclass(frozen=True) -class NoStatusThread(StatusThread): - name = "" - - def bind(self, activity: ObservedThreadActivity) -> None: - activity.show(ThreadStatusModel.pending(self.name)) - - def refresh(self, view: ThreadStatusOwner) -> None: - """A conversation without a status source has no row to refresh.""" - - -@dataclass(frozen=True) -class LocalStatusThread(StatusThread): - root: Path - name: str - - def bind(self, activity: ObservedThreadActivity) -> None: - activity.show(activity.app.coordination_access.thread_status.open(self.name)) - - def observe(self, activity: ObservedThreadActivity) -> None: - access = activity.app.coordination_access - activity.observe_core_callback( - access.events, ThreadRowsChanged, activity.status_changed, - factory=partial(ThreadCallbackSubscription, - scope=(str(self.root), self.name))) - - -@dataclass(frozen=True) -class ForeignStatusThread(StatusThread): - root: Path - name: str - - def bind(self, activity: ObservedThreadActivity) -> None: - activity.show(ThreadStatusModel.pending(self.name)) - - def refresh(self, view: ThreadStatusOwner) -> None: - super().refresh(view) - view.run_worker(self.read_foreign_thread(view), group="foreign-thread-status", exclusive=True) - - async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: - try: - presentation = await view.app.coordination_access.transcripts.thread_presentation( - str(self.root), self.name) - row = ThreadStatusModel.known(self.name, presentation) - except CoordinationReadUnavailable: - return # The next coordination change retries a busy store. - except (OSError, ValueError, RuntimeError) as error: - presentation = None - row = ThreadStatusModel.unreadable(self.name, UnreadableHistory.capture(error)) - if view.status_thread() != self: - return - view.thread_activity.show(row) - view.foreign_thread_read(presentation) +from toad.core_event_carrier import CoreEventReceiver +from toad.status_thread import NoStatusThread, StatusThread class ActivityView: diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index cab7c8739..98a048546 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -7,13 +7,13 @@ from agent_comms.launch_root import LaunchRoot from support.agents import peer -from toad.session_scope import NoSessionOrigin, SessionScope +from toad.session_scope import NoSessionOrigin, NoTranscript, SessionScope async def test_scope_import_and_real_conversation_ancestor(workspace): assert "toad.screens.session_view" not in sys.modules unavailable = NoSessionOrigin("closed-test-view") - assert unavailable.transcript_scope() is None + assert unavailable.transcript_scope() == NoTranscript() assert not unavailable.has_agent() with pytest.raises(RelationViolationError, match="closed-test-view"): unavailable.resolve_thread() diff --git a/tests/model/test_conversation_kind.py b/tests/model/test_conversation_kind.py index 4cb733956..a3cc18a90 100644 --- a/tests/model/test_conversation_kind.py +++ b/tests/model/test_conversation_kind.py @@ -2,6 +2,7 @@ from dataclasses import FrozenInstanceError, fields from functools import partial +from toad.status_thread import NoStatusThread import pytest @@ -96,7 +97,6 @@ def test_conversation_resolves_a_renamed_peer_before_presentation(tmp_path): def test_conversation_selects_the_saved_history_identity_and_channel_status(tmp_path): from toad.screens.historical_sessions import HistoricalSessions - from toad.widgets.observed_thread_activity import NoStatusThread comms = wire(tmp_path / "wire") create_view = partial(HistoricalSessions, comms, ()) diff --git a/tests/textual/test_block_menu_and_copy.py b/tests/textual/test_block_menu_and_copy.py index 7b21b9c0a..c27191f69 100644 --- a/tests/textual/test_block_menu_and_copy.py +++ b/tests/textual/test_block_menu_and_copy.py @@ -53,7 +53,10 @@ async def test_block_menu_copy_prompt_and_expansion(workspace, private_display): async with workspace.app(size=(110, 38)) as ui: app, _ = ui.textual view = ui.conversation - await view.contents.remove_children() + history = view.transcript.binding.history + await view.contents.remove_children( + tuple(child for child in view.contents.children if child is not history) + ) user = UserInput("USER_COPY_PAINT") extra = DeclaredExtraBlock("DECLARED_NEW_PAINT") tool = ToolCall(ToolCallStatus.from_acp(SDKToolCall.model_validate({ From c2c74d195b946d2dc5264f9a101edeeff5d24616 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 07:51:07 -0400 Subject: [PATCH 09/43] P401: match launches only through actual native session origins --- src/toad/navigation_preparation.py | 2 +- src/toad/screens/main.py | 9 +++++- src/toad/screens/welcome.py | 8 ++--- src/toad/session_entry.py | 18 +----------- src/toad/session_navigation.py | 6 ++-- src/toad/session_presentation.py | 14 ++------- src/toad/session_scope.py | 47 +++++++++++++++++++++--------- src/toad/thread_navigation.py | 6 ++-- src/toad/workspace_sessions.py | 8 ++++- tests/app/test_session_scope.py | 8 +++-- tests/app/test_welcome_view.py | 2 ++ 11 files changed, 70 insertions(+), 58 deletions(-) diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index e09ce6f67..a075f192c 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -94,7 +94,7 @@ class ExternalThreadNavigation(ThreadNavigation): def bind(self, screen) -> None: from toad.session_presentation import MailboxSessionSources - screen._agent = None + screen.use_mailbox() screen.presentation.sources = MailboxSessionSources() diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index b9f907baf..6135a4cfb 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -304,7 +304,7 @@ def identity_changed(self, previous) -> None: @classmethod def opening(cls, request, origin, app) -> "MainScreen": # The request has only a name; bind the wire after its metadata read. - screen = cls(request.project, origin.source._agent or app.agent_data, + screen = cls(request.project, origin.source.agent_for_opening(app.agent_data), agent_session_id=request.target) screen.content = NavigatedOpeningContent() return screen @@ -346,6 +346,13 @@ def retained_native_presentations(self): def session_disposition(self) -> SessionDisposition: return self.presentation.operational_binding.session_disposition + def make_conversation(self) -> Conversation: + return 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, + ) + @property def agent_session_id(self) -> str | None: """The saved ACP session this screen owns.""" diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 9791a8837..b34d687ba 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -24,7 +24,7 @@ from toad.navigation_target import FeedTarget, NavigationTarget from toad.navigation_target import channel_target, ThreadTarget from toad.screens.session_view import SessionView -from toad.session_scope import ViewSessionOrigin +from toad.session_scope import ProjectSessionOrigin from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction from toad.widgets.sidebar_tree import RowReconciliation, SidebarChange, SidebarGroup, SidebarModelView, TargetTree @@ -168,7 +168,7 @@ async def present_selected(self, event: CoreEventMessage) -> None: self.present() -class WelcomeScreen(ViewSessionOrigin, SessionView, can_focus=False): +class WelcomeScreen(ProjectSessionOrigin, SessionView, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" BINDINGS = [ @@ -189,11 +189,11 @@ class WelcomeScreen(ViewSessionOrigin, SessionView, can_focus=False): def __init__(self) -> None: super().__init__() app = cast("ToadApp", self.app) - self._agent = app.agent_data self.project_path = app.project_dir def compose(self) -> ComposeResult: - agent = f"{self._agent.name} · " if self._agent is not None else "" + selected = self.app.agent_data + agent = f"{selected.name} · " if selected is not None else "" with VerticalScroll(can_focus=False): yield WelcomeTitle() yield Static(f"{agent}{format_path(self.project_path)}", classes="subtitle", markup=False) diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index e0c99b381..b2363be1c 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -61,9 +61,6 @@ def thread_row(self, sessions: SessionNavigation, row: ThreadRowModel) -> Thread """How this entry's own session changes a thread's row; most entries hold none.""" return row - def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, session_id: str) -> bool: - return False - def depends_on(self, mode: str) -> bool: return self.mode == mode @@ -107,7 +104,7 @@ def original_threads(self, sessions): def __call__(self) -> MainScreen: source = self.factory() - self.details.bind_initial_identity(source._agent_session_id) + self.details.bind_initial_identity(source.agent_session_id) return source @property @@ -143,19 +140,6 @@ async def reconnect(self, sessions, selected, snapshot, targets): return await source.presentation.operational_binding.reconnect_session(source, plan) - def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, session_id: str) -> bool: - source = self.source(sessions) - if source is None or not source.has_agent(): - return False - if source._agent.identity != agent_identity: - return False - live = source.conversation.agent_binding - if session_id == source._agent_session_id or live.matches_session(session_id): - return True - root = source.coordination_root - return root is not None and sessions.app.coordination_access.require( - RouteSelection.capture(root)).service.registry.canonical_name(session_id) == source.resolve_thread() - def unobserved_tab(self) -> OpenTab: return NamedTab(self.mode, self.details.name.display or "New Session") diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index bba529afd..a3b1afe7d 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -171,10 +171,10 @@ async def launch(self, agent_identity: str, *, agent_session_id: str | None = No return project = project_path if project_path is not None else app.project_dir if agent_session_id is not None: - existing = next((entry for entry in self.members - if entry.accepts_launch(self, agent_identity, agent_session_id)), None) + existing = next((identity for identity, origin in app.workspace_sessions.native_origins.items() + if origin.matches_launch(agent_identity, agent_session_id)), None) if existing is not None: - await app.select_session(existing.mode) + await app.select_session(existing) return await self.new(lambda: MainScreen(project, agent, agent_session_id, session_pk=session_pk, initial_prompt=initial_prompt), diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 2dc84a5d1..e1d09a534 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -121,14 +121,6 @@ def activity_row(self, conversation: Conversation): def agent_info(self, conversation: Conversation) -> str: return "shell" - def make_conversation(self, screen: "MainScreen") -> Conversation: - return Conversation( - screen.project_path, screen._agent, screen._agent_session_id, - screen._session_pk, screen.app.session_tracker.sessions[screen.id].initial_title, - initial_prompt=screen._initial_prompt, - transcript=screen.presentation.transcript, - ) - def external_transcript_changed(self, conversation: Conversation) -> None: """Core publishes native and CLI drafts through the same transcript store.""" from toad.transcript_publication import CanonicalSourcePublication @@ -192,10 +184,10 @@ def create_agent(self, conversation: Conversation) -> None: def attach_started(self, screen: "MainScreen", plan) -> None: """An explicit Start action may attach the same retained view to its new owner.""" self.load_plan = plan - screen._agent = screen._agent or screen.app.agent_data + agent = screen.attach_agent_definition() conversation = screen.presentation.widget if conversation is not None: - conversation._agent_data = screen._agent + conversation._agent_data = agent if screen.is_current: conversation.start_native_session() @@ -330,7 +322,7 @@ async def acquire(self, screen: "MainScreen"): content = slot.parent assert isinstance(content, Widget) with screen._context(): - conversation = self.sources.make_conversation(screen) + conversation = screen.make_conversation() self.widget = conversation screen._initial_prompt = None if not returning: diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 9378749b5..9eaf7d635 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -133,14 +133,11 @@ class SessionOrigin: @abstractmethod def transcript_scope(self) -> TranscriptScope: ... - @abstractmethod - def has_agent(self) -> bool: ... - @abstractmethod def resolve_thread(self) -> str: ... @abstractmethod - def require(self) -> ViewSessionOrigin: ... + def require(self) -> ProjectSessionOrigin: ... class NoSessionOrigin(SessionOrigin): @@ -150,9 +147,6 @@ def __init__(self, view: str = "unavailable view") -> None: def transcript_scope(self) -> NoTranscript: return NoTranscript() - def has_agent(self) -> bool: - return False - def require(self) -> Never: raise RelationViolationError(f"View {self.view!r} has no session origin") @@ -218,10 +212,8 @@ def close_core_events(self) -> None: ... def remove(self) -> Awaitable[None]: ... -class ViewSessionOrigin(SessionOrigin, SessionScope): - """The existing operational agent/project origin shared by real views.""" - - _agent: AgentDefinition | None +class ProjectSessionOrigin(SessionOrigin, SessionScope): + """A project source may navigate and resolve threads without an ACP session.""" @property def origin(self) -> SessionOrigin: @@ -230,7 +222,7 @@ def origin(self) -> SessionOrigin: def transcript_scope(self) -> TranscriptScope: return self.thread_identity.transcript_scope() - def require(self) -> ViewSessionOrigin: + def require(self) -> ProjectSessionOrigin: return self def resolve_thread(self) -> str: @@ -239,5 +231,32 @@ def resolve_thread(self) -> str: service = self.app.coordination_access.require(selected).service return identity.resolve_thread(service.registry, self.project_root) - def has_agent(self) -> bool: - return self._agent is not None + def agent_for_opening(self, default: AgentDefinition | None) -> AgentDefinition | None: + return default + + +class ViewSessionOrigin(ProjectSessionOrigin): + """Only actual native-session views own agent/session launch matching.""" + + _agent: AgentDefinition | None + _agent_session_id: str | None + + def agent_for_opening(self, default: AgentDefinition | None) -> AgentDefinition | None: + return self._agent or default + + def attach_agent_definition(self) -> AgentDefinition | None: + self._agent = self._agent or self.app.agent_data + return self._agent + + def use_mailbox(self) -> None: + self._agent = None + + def matches_launch(self, agent_identity: str, session_id: str) -> bool: + if self._agent is None or self._agent.identity != agent_identity: + return False + live = self.presentation.operational_binding + if session_id == self._agent_session_id or live.matches_session(session_id): + return True + root = self.coordination_root + return root is not None and self.app.coordination_access.require( + RouteSelection.capture(root)).service.registry.canonical_name(session_id) == self.resolve_thread() diff --git a/src/toad/thread_navigation.py b/src/toad/thread_navigation.py index 2c6f0f910..b904cbd3e 100644 --- a/src/toad/thread_navigation.py +++ b/src/toad/thread_navigation.py @@ -12,19 +12,19 @@ if TYPE_CHECKING: from toad.app import ToadApp from toad.screens.main import MainScreen - from toad.session_scope import ViewSessionOrigin + from toad.session_scope import ProjectSessionOrigin from toad.navigation_preparation import ThreadNavigation @dataclass(frozen=True) class ThreadOrigin: - source: ViewSessionOrigin + source: ProjectSessionOrigin selected_mode: str root: str | None identity: str @classmethod - def capture(cls, app: ToadApp, source: ViewSessionOrigin) -> ThreadOrigin: + def capture(cls, app: ToadApp, source: ProjectSessionOrigin) -> ThreadOrigin: return cls(source, app.selected_mode, source.coordination_root, source.thread_identity.thread_name()) def current(self, navigator: ThreadNavigator, owner_mode: str) -> bool: diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 4e45cc340..899c04c72 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -7,7 +7,7 @@ from agent_comms.declared_family import DeclaredFamily from toad.constants import WELCOME -from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope +from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope, ViewSessionOrigin from toad.navigation_target import NavigationTarget, NoTarget if TYPE_CHECKING: @@ -145,6 +145,12 @@ def __init__(self, app: "ToadApp") -> None: self.views: dict[str, SessionScope] = {} self.source: WorkspaceSource = DetachedWorkspaceSource() + @property + def native_origins(self) -> dict[str, ViewSessionOrigin]: + """Derive actual native membership from the existing mounted-view owner.""" + return {identity: view for identity, view in self.views.items() + if isinstance(view, ViewSessionOrigin)} + def require(self, identity: str) -> "SessionScope": return self.views[identity] diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index 98a048546..b107c2fbb 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -14,11 +14,11 @@ async def test_scope_import_and_real_conversation_ancestor(workspace): assert "toad.screens.session_view" not in sys.modules unavailable = NoSessionOrigin("closed-test-view") assert unavailable.transcript_scope() == NoTranscript() - assert not unavailable.has_agent() with pytest.raises(RelationViolationError, match="closed-test-view"): unavailable.resolve_thread() LaunchRoot.from_path(workspace.roots.wire).install() - async with workspace.app(agent=peer("echo")) as ui: + definition = peer("echo") + async with workspace.app(agent=definition) as ui: app, _pilot = ui.textual first = ui.selected_tab view = app.selected_session @@ -27,6 +27,8 @@ async def test_scope_import_and_real_conversation_ancestor(workspace): await ui.write_draft("session scope journey") await ui.press("enter") await ui.until_shows("PEER_ANSWER session scope journey") + assert app.workspace_sessions.native_origins[first] is view + assert view.matches_launch(definition.identity, ui.conversation.agent.session_id) sidebar = app.workspace_chrome.channels if sidebar.collapsed: sidebar.toggle() @@ -44,6 +46,6 @@ async def test_scope_import_and_real_conversation_ancestor(workspace): assert ui.conversation.query_ancestor(SessionScope) is view await ui.until_shows("PEER_ANSWER session scope journey") await ui.close_tab(second) - assert not app.workspace_sessions.origin(second).has_agent() + assert second not in app.workspace_sessions.native_origins with pytest.raises(RelationViolationError): app.workspace_sessions.origin(second).resolve_thread() diff --git a/tests/app/test_welcome_view.py b/tests/app/test_welcome_view.py index 90092d6a4..400e54434 100644 --- a/tests/app/test_welcome_view.py +++ b/tests/app/test_welcome_view.py @@ -21,6 +21,8 @@ async def test_starting_opens_no_tab_and_starts_no_agent(workspace): async with workspace.app(session=False, agent=peer("echo")) as ui: await ui.until_shows(THREADS) assert ui.tabs() == () + app, _ = ui.textual + assert app.workspace_sessions.native_origins == {} assert ui.shows(HINT) and ui.shows(CHANNELS) assert not workspace.child_processes("echo.py"), "starting Toad started its agent" assert not workspace.received() From 643ca9e9f60859a07d68e09414e15e3260cbae31 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 07:54:16 -0400 Subject: [PATCH 10/43] P401: check live launch matching through the existing agent binding --- tests/app/test_session_scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index b107c2fbb..ede071206 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -28,7 +28,7 @@ async def test_scope_import_and_real_conversation_ancestor(workspace): await ui.press("enter") await ui.until_shows("PEER_ANSWER session scope journey") assert app.workspace_sessions.native_origins[first] is view - assert view.matches_launch(definition.identity, ui.conversation.agent.session_id) + assert view.matches_launch(definition.identity, ui.conversation.agent_binding.session_id) sidebar = app.workspace_chrome.channels if sidebar.collapsed: sidebar.toggle() From df7fc97161bfde0a49eb88d7f4d1d1553cb2fc57 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 08:06:30 -0400 Subject: [PATCH 11/43] P401: format the touched session ownership family --- src/toad/acp/transcript_reader.py | 61 +- src/toad/application_actions.py | 66 +- src/toad/block_navigation.py | 25 +- src/toad/comms_root.py | 177 +++-- src/toad/conversation_kind.py | 52 +- src/toad/navigation_preparation.py | 50 +- src/toad/navigation_target.py | 62 +- src/toad/project_path_owner.py | 20 +- src/toad/screens/comms.py | 52 +- src/toad/screens/main.py | 93 ++- src/toad/screens/session_view.py | 19 +- src/toad/screens/welcome.py | 67 +- src/toad/screens/workspace.py | 80 ++- src/toad/session_entry.py | 154 ++++- src/toad/session_navigation.py | 220 ++++-- src/toad/session_presentation.py | 134 +++- src/toad/session_scope.py | 57 +- src/toad/status_thread.py | 31 +- src/toad/thread_identity.py | 8 +- src/toad/thread_navigation.py | 38 +- src/toad/transcript_preparation.py | 182 +++-- src/toad/transcript_publication.py | 268 ++++++-- src/toad/transcript_state.py | 53 +- src/toad/widgets/channel_activity.py | 18 +- src/toad/widgets/comms_chat.py | 90 ++- src/toad/widgets/context_explorer.py | 177 +++-- src/toad/widgets/conversation.py | 385 +++++++---- src/toad/widgets/side_bar.py | 140 +++- src/toad/widgets/sidebar_tree.py | 152 +++-- src/toad/widgets/thread_comms.py | 246 +++++-- src/toad/widgets/thread_identity.py | 1 + src/toad/widgets/transcript_history.py | 662 ++++++++++++++----- src/toad/widgets/wire_history.py | 67 +- src/toad/wire_history.py | 160 ++++- src/toad/workspace_chrome.py | 54 +- src/toad/workspace_sessions.py | 32 +- tests/app/test_session_scope.py | 4 +- tests/app/test_welcome_view.py | 23 +- tests/model/test_conversation_kind.py | 74 ++- tests/native/test_native_client_requests.py | 169 +++-- tests/native/test_native_editor_queue.py | 116 +++- tests/support/native_steps.py | 156 +++-- tests/support/ui.py | 73 +- tests/textual/test_block_menu_and_copy.py | 53 +- tests/textual/test_conversation_value.py | 68 +- tests/textual/test_navigation_route_reads.py | 50 +- tests/textual/test_sidebar_destinations.py | 8 +- tests/textual/test_tab_underline.py | 55 +- 48 files changed, 3754 insertions(+), 1248 deletions(-) diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index 00630c7f6..5f92ba731 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -18,7 +18,8 @@ from agent_comms.route_selection import RouteSelection from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate from agent_comms.ui_model.observation import ( - AwaitedRead, RefreshTranscript, + AwaitedRead, + RefreshTranscript, TranscriptPublicationState, ) @@ -32,12 +33,15 @@ def __init__(self, access): self.access = access async def read(self, request: AwaitedRead): - observed = await self.access.preparation.run_thread(self.access.observation.observed) + observed = await self.access.preparation.run_thread( + self.access.observation.observed + ) return await observed.process.read(request) async def service(self, root): return await self.access.preparation.run_thread( - lambda: self.access.require(RouteSelection.capture(root)).service) + lambda: self.access.require(RouteSelection.capture(root)).service + ) async def thread_presentation(self, root: str, thread: str): """Read one thread presentation through this reader's bound service.""" @@ -50,24 +54,55 @@ async def thread_presentation(self, root: str, thread: str): async def bind(self, root: str): yield await self.service(root) - async def publication(self, update: TranscriptSnapshotUpdate) -> TranscriptSnapshotUpdate: + async def publication( + self, update: TranscriptSnapshotUpdate + ) -> TranscriptSnapshotUpdate: """The published page while its witness is current, else the current page.""" identity = update.identity current = await self.read(RefreshTranscript(identity.root, identity)) return update if current is None else current - async def page(self, scope: TranscriptScope, *, before=None, after=None, through=None, - historical_source: str | None = None): - snapshot = await self.snapshot(scope, before=before, after=after, through=through, - historical_source=historical_source) + async def page( + self, + scope: TranscriptScope, + *, + before=None, + after=None, + through=None, + historical_source: str | None = None, + ): + snapshot = await self.snapshot( + scope, + before=before, + after=after, + through=through, + historical_source=historical_source, + ) return snapshot.page - async def snapshot(self, scope: TranscriptScope, *, before=None, after=None, through=None, - historical_source: str | None = None) -> TranscriptSnapshotUpdate: - return await self.read(scope.snapshot_request( - before=before, after=after, through=through, historical_source=historical_source)) + async def snapshot( + self, + scope: TranscriptScope, + *, + before=None, + after=None, + through=None, + historical_source: str | None = None, + ) -> TranscriptSnapshotUpdate: + return await self.read( + scope.snapshot_request( + before=before, + after=after, + through=through, + historical_source=historical_source, + ) + ) async def updates( - self, scope: TranscriptScope, previous: TranscriptPublicationState, *, after=None, + self, + scope: TranscriptScope, + previous: TranscriptPublicationState, + *, + after=None, ) -> TranscriptSnapshotUpdate | TranscriptDraftUpdate: return await self.read(scope.update_request(previous, after=after)) diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index 90b522fa2..b52d0e81b 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -1,4 +1,5 @@ """Textual actions, bindings and palette entries share their declarations.""" + from __future__ import annotations from abc import abstractmethod @@ -6,7 +7,12 @@ from typing import TYPE_CHECKING, ClassVar from textual.command import DiscoveryHit, Hit, Hits, Provider from toad.preferences import SETTINGS -from toad.widget_actions import ApplicationAction, KeyboundAction, MenuAction, VimApplicationAction +from toad.widget_actions import ( + ApplicationAction, + KeyboundAction, + MenuAction, + VimApplicationAction, +) from toad.target_commands import TargetContext from toad.thread_actions import ThreadAction from agent_comms.errors import RelationViolationError @@ -34,15 +40,29 @@ async def apply(self, app: ToadApp) -> None: project = app.workspace_sessions.origin(mode).require().project_root except RelationViolationError: project = app.project_dir - app.run_worker(partial(self.collect, app, mode, project), group="new-session", exclusive=True) + app.run_worker( + partial(self.collect, app, mode, project), + group="new-session", + exclusive=True, + ) async def collect(self, app, mode, project): def prepare(): - ctx = TargetContext(app, app.coordination_access.observation.observed().service, - ProjectTarget(str(project)), '', project, mode) - definition = next(action for action in ctx.available_actions() - if action.declaration is NewThreadCliCommand) + ctx = TargetContext( + app, + app.coordination_access.observation.observed().service, + ProjectTarget(str(project)), + "", + project, + mode, + ) + definition = next( + action + for action in ctx.available_actions() + if action.declaration is NewThreadCliCommand + ) return partial(ThreadAction.collect, ctx, definition) + open_form = await app.preparation.run_thread(prepare) if app.selected_mode == mode: open_form() @@ -64,6 +84,7 @@ async def apply(self, app: ToadApp) -> None: async def edit(self, app: ToadApp) -> None: from toad.screens.settings import SettingsScreen + await app.push_screen_wait(SettingsScreen()) await app.saved_settings.save() @@ -82,15 +103,25 @@ def parse(cls, parameters: tuple[object, ...]) -> SetFooterAction: async def apply(self, app: ToadApp) -> None: app.saved_settings.set(SETTINGS.ui.footer, self.visible) await app.saved_settings.save() - app.notify("Footer shortcut bar shown" if self.visible else "Footer shortcut bar hidden", - title="Interface") + app.notify( + ( + "Footer shortcut bar shown" + if self.visible + else "Footer shortcut bar hidden" + ), + title="Interface", + ) class ToggleFooterAction(ApplicationAction, PaletteAction): help = "Toggle the key-shortcut bar at the bottom of Toad" def label(self, app: ToadApp) -> str: - return "Hide footer shortcut bar" if app.settings.ui.footer else "Show footer shortcut bar" + return ( + "Hide footer shortcut bar" + if app.settings.ui.footer + else "Show footer shortcut bar" + ) async def apply(self, app: ToadApp) -> None: await SetFooterAction(not app.settings.ui.footer).apply(app) @@ -170,6 +201,7 @@ class VimNavigationAction(VimApplicationAction): @staticmethod def conversation(app): from toad.widgets.conversation import Conversation + view = app.selected_session return None if view is None else view.query_one_optional(Conversation) @@ -231,6 +263,7 @@ class VimFocusPromptAction(VimNavigationAction): async def apply(self, app): from textual.widgets.text_area import VimInsert + conversation = self.conversation(app) conversation.prompt.prompt_text_area.editing_mode = VimInsert() conversation.focus_prompt(reset_cursor=False, scroll_end=False) @@ -259,11 +292,18 @@ async def search(self, query: str) -> Hits: command = declaration() label = command.label(self.app) if score := matcher.match(label): - yield Hit(score, matcher.highlight(label), partial(command.apply, self.app), - help=command.help) + yield Hit( + score, + matcher.highlight(label), + partial(command.apply, self.app), + help=command.help, + ) async def discover(self) -> Hits: for declaration in ApplicationAction.members_with(PaletteAction): command = declaration() - yield DiscoveryHit(command.label(self.app), partial(command.apply, self.app), - help=command.help) + yield DiscoveryHit( + command.label(self.app), + partial(command.apply, self.app), + help=command.help, + ) diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 2077a4323..68dea2743 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -1,4 +1,5 @@ """Blocks declare cursor behavior; one component owns content selection.""" + from __future__ import annotations from abc import abstractmethod @@ -19,6 +20,7 @@ class CursorDirection(PageEnd, DeclaredFamily, affix="Cursor"): @abstractmethod def enters_from_prompt(self) -> bool: ... + class UpCursor(OlderEnd, CursorDirection): enters_from_prompt = True @@ -164,7 +166,11 @@ def __init__(self, block): def current(self): """The message the cursor rests on, while the history still draws it.""" fragment = self.fragment - return fragment if fragment is not None and self.block.draws_fragment(fragment) else None + return ( + fragment + if fragment is not None and self.block.draws_fragment(fragment) + else None + ) @property def selected(self): @@ -182,10 +188,15 @@ def enter(self, direction): def move(self, direction): fragments = self.block.drawn_fragments current = self.current - if current is None or all(current is not seen for seen in self.block.visible_fragments()): + if current is None or all( + current is not seen for seen in self.block.visible_fragments() + ): # The reader scrolled away from the cursor: it starts again from what they see. return self.enter(direction) - index = next(index for index, drawn in enumerate(fragments) if drawn is current) + direction.step + index = ( + next(index for index, drawn in enumerate(fragments) if drawn is current) + + direction.step + ) if 0 <= index < len(fragments): self.fragment = fragments[index] return True @@ -254,7 +265,9 @@ def _enter_from(self, index, direction): return False def enter(self, direction): - return self._enter_from(direction.entry(len(self.block.displayed_children)), direction) + return self._enter_from( + direction.entry(len(self.block.displayed_children)), direction + ) def move(self, direction): child = self.current @@ -314,7 +327,9 @@ def message_category(self) -> type[MessageCategory]: raise NotImplementedError def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> None: - self.set_display_constraint("message-category", self.message_category in selected) + self.set_display_constraint( + "message-category", self.message_category in selected + ) def starts_agent_activity(self, boundary) -> bool: return self.message_category.observe(boundary) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 0c3fc0ec2..9fa8bde80 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -13,36 +13,56 @@ import asyncio import threading -from dataclasses import dataclass, field -from functools import partial from collections.abc import Callable, Mapping from contextlib import ExitStack -from typing import TYPE_CHECKING, TypeVar +from dataclasses import dataclass, field from pathlib import Path +from typing import TYPE_CHECKING, TypeVar from weakref import WeakKeyDictionary, WeakSet from agent_comms.coordination_errors import StaleRevision +from agent_comms.display_references import PendingReferences, UnavailableReferences from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.presentation import MessageNotification, NoRevision, ReadState from agent_comms.revision_views import ExternalTranscriptsView, StatusView from agent_comms.route_selection import RouteChanged, RouteSelection, run_selected_write from agent_comms.ui_model.observation import ( - ObserveNotifications, NotificationsObserved, NotificationsUnreadable, ReadNotifications, - ObservationProcess, ObserveSidebar, ObserveThreads, ObserveViews, RevisionObserved, - SidebarObserved, SidebarUnreadable, ViewsRetired, - StopObserving, StopSidebar, ThreadsObserved, + NotificationsObserved, + NotificationsUnreadable, + ObservationProcess, + ObserveNotifications, + ObserveSidebar, + ObserveThreads, + ObserveViews, + ReadNotifications, + RevisionObserved, + SidebarObserved, + SidebarUnreadable, + StopObserving, + StopSidebar, + ThreadsObserved, + ViewsRetired, ) from agent_comms.ui_model.sidebar import SidebarModel, SidebarObservation from agent_comms.ui_model.status import OpenThreadStatus -from toad.core.events import CoreEventStream, CoordinationObserved, ExternalTranscriptChanged, OpenTabsChanged, LinkContextChanged, ObservedServiceChanged, ThreadRowsChanged -from toad.resource_release import ResourceRelease + +from toad.core.events import ( + CoordinationObserved, + CoreEventStream, + ExternalTranscriptChanged, + LinkContextChanged, + ObservedServiceChanged, + OpenTabsChanged, + ThreadRowsChanged, +) from toad.preferences import SETTINGS -from agent_comms.display_references import PendingReferences, UnavailableReferences +from toad.resource_release import ResourceRelease if TYPE_CHECKING: - from toad.widgets.message_notifications import HistoryNotifications from agent_comms.comms import Comms + from toad.app import ToadApp + from toad.widgets.message_notifications import HistoryNotifications T = TypeVar("T") @@ -57,7 +77,9 @@ class UnselectedRoute: @dataclass(frozen=True, kw_only=True) class Observation: access: CoordinationAccess = field(compare=False) - subscriptions: ResourceRelease = field(default_factory=ResourceRelease, compare=False) + subscriptions: ResourceRelease = field( + default_factory=ResourceRelease, compare=False + ) # View updates end before the views close; `subscriptions` retires the observation after them. updates: ResourceRelease = field(default_factory=ResourceRelease, compare=False) @@ -78,10 +100,13 @@ def tab(self, entry, sessions): def tabs(self, sessions): return {entry.mode: self.tab(entry, sessions) for entry in sessions.members} + def require(self, access, selected): return access.open_selected(selected) + def references_for(self, root): return PendingReferences(root) + async def current_references(self, access, root): return access.reference_context(root) @@ -90,7 +115,9 @@ def failed(self, error, selected): if failed == self: return self self.access.observation = failed - self.access.app.running_loop.call_soon_threadsafe(failed.attach, self.access, self) + self.access.app.running_loop.call_soon_threadsafe( + failed.attach, self.access, self + ) return failed def stop_updates(self) -> Observation: @@ -105,7 +132,9 @@ def attach(self, access, previous): waiters, access.sidebar_waiters = access.sidebar_waiters, [] for _, waiter in waiters: if not waiter.done(): - waiter.set_exception(RouteChanged("Comms service changed before the sidebar was read")) + waiter.set_exception( + RouteChanged("Comms service changed before the sidebar was read") + ) access.revision = NoRevision() access.sidebar_interest = access.sidebar_current = None access.thread_status.clear() @@ -130,9 +159,11 @@ class FailedObservation(Observation): def __eq__(self, other: object) -> bool: # Exceptions declare their type, constructor fields and extra state through # reconstruction; OSError includes filenames that are absent from args. - return (isinstance(other, FailedObservation) - and self.selection == other.selection - and self.error.__reduce__() == other.error.__reduce__()) + return ( + isinstance(other, FailedObservation) + and self.selection == other.selection + and self.error.__reduce__() == other.error.__reduce__() + ) @property def service(self): @@ -192,7 +223,12 @@ async def write_order(self, access, root, channel, order): if root is not None and not self.observes(root): raise ValueError("Comms route changed before sorting") return await access.preparation.run_thread( - access.write, self.selection, order.store_channel_order, self.service.channels, channel) + access.write, + self.selection, + order.store_channel_order, + self.service.channels, + channel, + ) def require(self, access, selected): return self if self.selection == selected else access.open_selected(selected) @@ -202,7 +238,9 @@ def activate(self, access): access.update_sidebar_interest() self.thread_interest(access) self.updates.callback(self.answer_reads) - self.updates.enter_context(access.app.events.subscribe(OpenTabsChanged, self.views_changed)) + self.updates.enter_context( + access.app.events.subscribe(OpenTabsChanged, self.views_changed) + ) self.subscriptions.enter_context(self.updates) self.views_changed() @@ -214,12 +252,21 @@ def answer_reads(self): def stop_updates(self) -> AnsweringReads: super().stop_updates() return AnsweringReads( - self.selection, self.service, self.sidebar, self.process, self.loop, - access=self.access, subscriptions=self.subscriptions, updates=self.updates) + self.selection, + self.service, + self.sidebar, + self.process, + self.loop, + access=self.access, + subscriptions=self.subscriptions, + updates=self.updates, + ) def retire(self, access): self.loop.remove_reader(self.process.fileno()) - self.process.abandon_reads(StaleRevision("The Comms service was replaced before the read was answered")) + self.process.abandon_reads( + StaleRevision("The Comms service was replaced before the read was answered") + ) task = asyncio.ensure_future(asyncio.to_thread(self.process.close)) access.retiring.add(task) task.add_done_callback(access.retiring.discard) @@ -238,8 +285,16 @@ def views_changed(self, event=None, subscription=None): access, root = self.access, str(self.service.root) threads, channels = set(), set() for member in access.app.session_navigation.members: - threads.update(identity for owner, identity in member.original_threads(access.app.session_navigation) if owner == root) - channels.update(target for owner, target in member.original_channels() if owner == root) + threads.update( + identity + for owner, identity in member.original_threads( + access.app.session_navigation + ) + if owner == root + ) + channels.update( + target for owner, target in member.original_channels() if owner == root + ) request = ObserveViews(frozenset(threads), frozenset(channels)) if request != access.views_interest: access.views_interest = request @@ -256,7 +311,9 @@ async def current_references(self, access, root): return access.reference_context(root) def thread_interest(self, access): - self.process.request(ObserveThreads(access.events.observed_threads(str(self.service.root)))) + self.process.request( + ObserveThreads(access.events.observed_threads(str(self.service.root))) + ) def observe_sidebar(self, wanted): self.process.request(wanted if wanted is not None else StopSidebar()) @@ -264,6 +321,7 @@ def observe_sidebar(self, wanted): def observe_notifications(self, wanted): self.process.request(wanted) + class AnsweringReads(Observing): """Interests have ended; acquired reads keep their process and release owners.""" @@ -308,12 +366,18 @@ def __init__(self, app: ToadApp) -> None: # Views showing the sidebar model now, and consumers waiting for current rows. self.sidebar_views: WeakSet[object] = WeakSet() self.reference_views: WeakSet[object] = WeakSet() - self.sidebar_waiters: list[tuple[ObserveSidebar, asyncio.Future[SidebarObservation]]] = [] + self.sidebar_waiters: list[ + tuple[ObserveSidebar, asyncio.Future[SidebarObservation]] + ] = [] self.sidebar_interest: ObserveSidebar | None = None self.sidebar_current: ObserveSidebar | None = None self.views_interest: ObserveViews | None = None - self.notification_views: WeakKeyDictionary[HistoryNotifications, ReadNotifications] = WeakKeyDictionary() - self.notification_outcomes: dict[ReadNotifications, Mapping[tuple[int, str], tuple[MessageNotification, ...]]] = {} + self.notification_views: WeakKeyDictionary[ + HistoryNotifications, ReadNotifications + ] = WeakKeyDictionary() + self.notification_outcomes: dict[ + ReadNotifications, Mapping[tuple[int, str], tuple[MessageNotification, ...]] + ] = {} self.notification_interest = ObserveNotifications(frozenset()) def open_current(self) -> Observing | FailedObservation: @@ -340,11 +404,19 @@ def open_selected(self, selected: RouteSelection) -> Observing: with ExitStack() as acquisition: acquisition.enter_context(selected.route.hold_client_route()) service = wire(selected.route) - if service.root.resolve() != selected.root or RouteSelection.capture() != selected: + if ( + service.root.resolve() != selected.root + or RouteSelection.capture() != selected + ): raise RouteChanged("Comms route changed while opening the service") observed = Observing( - selected, service, SidebarModel(self.app.call_next), ObservationProcess(service.route), - self.app.running_loop, access=self) + selected, + service, + SidebarModel(self.app.call_next), + ObservationProcess(service.route), + self.app.running_loop, + access=self, + ) self.cleanup.enter_context(acquisition.pop_all()) observed.subscriptions.callback(observed.retire, self) self.observation = observed @@ -373,14 +445,18 @@ def sidebar_observed(self, result: SidebarObserved, observed: Observing) -> None self.events.publish(LinkContextChanged()) self.sidebar_current = result.request result.snapshot.read_marker_notice.notify_if_new( - notice, lambda text: app.notify(text, title="Read positions", severity="warning")) + notice, + lambda text: app.notify(text, title="Read positions", severity="warning"), + ) if app.tab_order.names != modes: app.events.publish(OpenTabsChanged()) for waiter in self.take_sidebar_waiters(result.request): waiter.set_result(observed.sidebar.observation) @handles(SidebarUnreadable) - def sidebar_unreadable(self, result: SidebarUnreadable, observed: Observing) -> None: + def sidebar_unreadable( + self, result: SidebarUnreadable, observed: Observing + ) -> None: if result.request != self.sidebar_interest: return observed.sidebar.clear(result.error) @@ -390,7 +466,9 @@ def sidebar_unreadable(self, result: SidebarUnreadable, observed: Observing) -> waiter.set_result(observed.sidebar.observation) self.app.show_error(result.error, "Sidebar unavailable") - def take_sidebar_waiters(self, answered: ObserveSidebar) -> list[asyncio.Future[SidebarObservation]]: + def take_sidebar_waiters( + self, answered: ObserveSidebar + ) -> list[asyncio.Future[SidebarObservation]]: """Retire answered reads once, leaving other interests subscribed.""" remaining, answered_waiters = [], [] for request, waiter in self.sidebar_waiters: @@ -405,13 +483,17 @@ def take_sidebar_waiters(self, answered: ObserveSidebar) -> list[asyncio.Future[ @handles(ThreadsObserved) def threads_observed(self, result: ThreadsObserved, observed: Observing) -> None: self.thread_status.observed(result) - self.events.publish(ThreadRowsChanged(names=result.changed, root=str(observed.service.root))) + self.events.publish( + ThreadRowsChanged(names=result.changed, root=str(observed.service.root)) + ) @handles(ViewsRetired) def views_retired(self, result: ViewsRetired, observed: Observing) -> None: self.app.session_navigation.views_retired(observed.service.root, result.retired) - def show_notifications(self, view: HistoryNotifications, read: ReadNotifications) -> None: + def show_notifications( + self, view: HistoryNotifications, read: ReadNotifications + ) -> None: self.notification_views[view] = read if read in self.notification_outcomes: view.notifications_observed(self.notification_outcomes[read]) @@ -431,7 +513,9 @@ def _request_interest(self) -> None: observed.observe_notifications(wanted) @handles(NotificationsObserved) - def notifications_observed(self, result: NotificationsObserved, observed: Observing) -> None: + def notifications_observed( + self, result: NotificationsObserved, observed: Observing + ) -> None: if result.request not in self.notification_views.values(): return self.notification_outcomes[result.request] = result.outcomes @@ -440,7 +524,9 @@ def notifications_observed(self, result: NotificationsObserved, observed: Observ view.notifications_observed(result.outcomes) @handles(NotificationsUnreadable) - def notifications_unreadable(self, result: NotificationsUnreadable, observed: Observing) -> None: + def notifications_unreadable( + self, result: NotificationsUnreadable, observed: Observing + ) -> None: self.notification_outcomes.pop(result.request, None) for view, request in tuple(self.notification_views.items()): if result.request == request: @@ -455,7 +541,11 @@ def sidebar_request(self) -> ObserveSidebar: ) def update_sidebar_interest(self) -> None: - wanted = self.sidebar_request() if self.sidebar_views or self.reference_views or self.sidebar_waiters else None + wanted = ( + self.sidebar_request() + if self.sidebar_views or self.reference_views or self.sidebar_waiters + else None + ) if wanted != self.sidebar_interest: observed = self.observation.observed() self.sidebar_interest = wanted @@ -542,8 +632,13 @@ async def observe_route(self) -> None: if await self.route_changed(): self.events.publish(CoordinationObserved(self.revision)) - def write(self, selected: RouteSelection, operation: Callable[..., T], *args: object, **kwargs: object) -> T: + def write( + self, + selected: RouteSelection, + operation: Callable[..., T], + *args: object, + **kwargs: object, + ) -> T: # This method runs in the same worker as the actual sink. The existing # core route lock is held through the operation; no second lock/store. return run_selected_write(selected.root, operation, *args, **kwargs) - diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index ac9ae202d..57546fc92 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -5,7 +5,11 @@ from abc import abstractmethod from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar -from agent_comms.historical_views import ChannelDisplayHistory, ConversationHistoryView, DMDisplayHistory +from agent_comms.historical_views import ( + ChannelDisplayHistory, + ConversationHistoryView, + DMDisplayHistory, +) from agent_comms.declared_family import DeclaredFamily from agent_comms.comms import Comms from agent_comms.message_page import MessagePage, PageBounds @@ -89,12 +93,17 @@ def activity_members(self, model): return (self.target.name,) def active_members(self, model): - return tuple(model.threads.rows[name] for name in self.activity_members(model) - if model.threads.rows[name].active) + return tuple( + model.threads.rows[name] + for name in self.activity_members(model) + if model.threads.rows[name].active + ) @classmethod def activity_details(cls, target: ThreadTarget): - return ConversationSessionDetails(target, history=NativeHistory(), delivery=InputDeliveryBar()) + return ConversationSessionDetails( + target, history=NativeHistory(), delivery=InputDeliveryBar() + ) @classmethod def compose_activity(cls, view) -> ComposeResult: @@ -126,7 +135,9 @@ class ChannelConversation(ConversationKind): history_view = ChannelDisplayHistory def activity_members(self, model): - return model.channels.rows.get(self.target.name, UnpaintedChannel(self.target.name)).members + return model.channels.rows.get( + self.target.name, UnpaintedChannel(self.target.name) + ).members def channels_needed(self, root): return ((root, self.target.name),) @@ -134,18 +145,21 @@ def channels_needed(self, root): @classmethod def view_identity(cls, key): from toad.session_tracker import ChannelViewAddress + return ChannelViewAddress(key.root, key.conversation) @classmethod def page(cls, source: WireSource, bounds: PageBounds) -> MessagePage: return source.comms.views.channel_display_page( - source.conversation.target.name, viewer=source.viewer, bounds=bounds) + source.conversation.target.name, viewer=source.viewer, bounds=bounds + ) def resolve(self, comms: Comms, me: str) -> ResolvedConversation: snapshot = comms.registry.snapshot() resolved = self.target.resolve(snapshot, comms.bus.require_channel) return ResolvedConversation( - snapshot.canonical_name(me), replace(self, target=replace(self.target, name=resolved.channel.name)), + snapshot.canonical_name(me), + replace(self, target=replace(self.target, name=resolved.channel.name)), ) @classmethod @@ -178,8 +192,13 @@ async def update_mentions(cls, view, comms): observed = access.observation if not observed.uses(comms): raise ValueError("Channel mentions belong to another Comms service") - mentions = await view.app.preparation.run_thread(snapshot.mention_candidates, conversation.target.name) - if not await history.source_is_current(source) or view.conversation != conversation: + mentions = await view.app.preparation.run_thread( + snapshot.mention_candidates, conversation.target.name + ) + if ( + not await history.source_is_current(source) + or view.conversation != conversation + ): return view.query_one(ChannelPrompt).set_mention_candidates(mentions) @@ -200,7 +219,10 @@ async def toggle_dm(cls, screen) -> None: await screen.action_back_to_agent() def threads_needed(self, snapshot, me): - return (snapshot.require(me).incarnation, snapshot.require(self.target.name).incarnation) + return ( + snapshot.require(me).incarnation, + snapshot.require(self.target.name).incarnation, + ) def unread(self, observation): return observation.bus_unread(self.target.name) @@ -208,13 +230,15 @@ def unread(self, observation): @classmethod def page(cls, source: WireSource, bounds: PageBounds) -> MessagePage: return source.comms.views.dm_display_page( - source.conversation.target.name, worktree=str(source.project), bounds=bounds) + source.conversation.target.name, worktree=str(source.project), bounds=bounds + ) def resolve(self, comms: Comms, me: str) -> ResolvedConversation: snapshot = comms.registry.snapshot() resolved = self.target.resolve(snapshot, comms.bus.require_channel) return ResolvedConversation( - snapshot.require(me).name, replace(self, target=replace(self.target, name=resolved.thread.name)), + snapshot.require(me).name, + replace(self, target=replace(self.target, name=resolved.thread.name)), ) def label(self) -> str: @@ -230,7 +254,9 @@ def prompt(self): return MessagePrompt(placeholder=self.placeholder()) async def status_text(self, comms, *, read_only: bool) -> str: - return (await asyncio.to_thread(comms.configuration.answer, self.target.name)).status_label + return ( + await asyncio.to_thread(comms.configuration.answer, self.target.name) + ).status_label class IrcConversation(ChannelConversation): diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index a075f192c..27c638ad9 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -58,7 +58,9 @@ def read(self) -> CommsNavigation: return CommsNavigation( CommsViewKey(str(root), self.owner_mode, resolved.conversation), owner_root, - resolved.conversation.threads_needed(comms.registry.snapshot(), resolved.viewer), + resolved.conversation.threads_needed( + comms.registry.snapshot(), resolved.viewer + ), ) @@ -85,7 +87,9 @@ def launch_screen(self, app): """A named launch retains the same acquired identity as a sidebar open.""" from toad.screens.main import MainScreen - screen = MainScreen.from_thread(self.project, self.root, self.thread.incarnation, app.agent_data) + screen = MainScreen.from_thread( + self.project, self.root, self.thread.incarnation, app.agent_data + ) self.bind(screen) return screen @@ -114,7 +118,10 @@ async def open(self, opening: ThreadOpening) -> str: navigator = opening.navigator source = navigator.app.session_navigation.source(self.existing.mode) if source is not None: - if (source.coordination_root, source.thread_identity.thread_name()) == (self.existing.root, self.existing.name): + if (source.coordination_root, source.thread_identity.thread_name()) == ( + self.existing.root, + self.existing.name, + ): await navigator.app.session_navigation.close_many((opening.view.id,)) await navigator.app.select_session(self.existing.mode) # An obsolete open-view identity must never manufacture a duplicate. @@ -138,13 +145,20 @@ def read(self) -> ThreadNavigation: project = Path(thread.worktree) if not project.is_dir(): project = self.project - existing = next((view for view in self.open_threads - if view.root == self.root - and snapshot.canonical_name(view.name) == thread.name), None) + existing = next( + ( + view + for view in self.open_threads + if view.root == self.root + and snapshot.canonical_name(view.name) == thread.name + ), + None, + ) if existing is not None: return ExistingThreadNavigation(self.root, thread, project, existing) return thread.execution.prepare_conversation( - ThreadConversationPreparation(self.root, thread, project, snapshot)) + ThreadConversationPreparation(self.root, thread, project, snapshot) + ) @dataclass(frozen=True) @@ -168,8 +182,13 @@ def native(self) -> ThreadNavigation: class ThreadOpening: """The actual unfinished task owns completion and survives one waiter.""" - def __init__(self, navigator: ThreadNavigator, owner_mode: str, - request: ThreadNavigationRequest, origin: ThreadOrigin) -> None: + def __init__( + self, + navigator: ThreadNavigator, + owner_mode: str, + request: ThreadNavigationRequest, + origin: ThreadOrigin, + ) -> None: from toad.screens.main import MainScreen self.navigator, self.owner_mode = navigator, owner_mode @@ -191,9 +210,14 @@ async def run(self) -> str: if prepared is None: await navigator.app.session_navigation.close_many((self.view.id,)) return navigator.app.selected_mode - if navigator.app.workspace_sessions.views.get(self.view.id) is not self.view: + if ( + navigator.app.workspace_sessions.views.get(self.view.id) + is not self.view + ): return navigator.app.selected_mode - if not await navigator.app.preparation.run_thread(root_is_current, self.request.root): + if not await navigator.app.preparation.run_thread( + root_is_current, self.request.root + ): raise RuntimeError("The thread's bus route changed while opening") return await prepared.open(self) except RequestError as error: @@ -227,7 +251,9 @@ async def read(self, request: NavigationRequest[ResultT]) -> ResultT | None: self._slots.release() return None try: - pending = asyncio.create_task(asyncio.to_thread(request.read), name="navigation-read") + pending = asyncio.create_task( + asyncio.to_thread(request.read), name="navigation-read" + ) except BaseException: self._slots.release() raise diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 9f385a647..550dd9665 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -4,11 +4,21 @@ from abc import abstractmethod from agent_comms.declared_family import DeclaredFamily -from agent_comms.channel_targets import ChannelSelection, CommandTarget, ChannelTarget as CoreChannelTarget, ThreadTarget as CoreThreadTarget +from agent_comms.channel_targets import ( + ChannelSelection, + CommandTarget, + ChannelTarget as CoreChannelTarget, + ThreadTarget as CoreThreadTarget, +) from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar -from toad.conversation_kind import ConversationKind, ChannelConversation, DmConversation, IrcConversation +from toad.conversation_kind import ( + ConversationKind, + ChannelConversation, + DmConversation, + IrcConversation, +) from toad.constants import ALL_COMMS_TARGET if TYPE_CHECKING: @@ -33,9 +43,16 @@ def named(cls, name: str) -> NavigationTarget: def menu_context(self, sidebar, *, channel: ChannelSelection, mode_name=None): from toad.target_commands import TargetContext - return TargetContext(sidebar.app, sidebar.model_state.observed.service, self.name, - sidebar.app.workspace_sessions.source.view.navigation_context.actor, - sidebar.app.project_dir, mode_name, channel) + + return TargetContext( + sidebar.app, + sidebar.model_state.observed.service, + self.name, + sidebar.app.workspace_sessions.source.view.navigation_context.actor, + sidebar.app.project_dir, + mode_name, + channel, + ) def selected(self, owner: NavigationOwner) -> None: """Apply view-specific selection intent before beginning navigation.""" @@ -78,7 +95,11 @@ async def open(self, context: NavigationContext) -> str: class SessionTarget(NavigationTarget): async def open(self, context: NavigationContext) -> str: app = context.app - destination = context.owner_mode if app.session_tracker.get_session(context.owner_mode) else "store" + destination = ( + context.owner_mode + if app.session_tracker.get_session(context.owner_mode) + else "store" + ) if app.selected_mode != destination: await app.select_session(destination) return app.selected_mode @@ -87,7 +108,9 @@ async def open(self, context: NavigationContext) -> str: class ThreadTarget(NavigationTarget): async def open(self, context: NavigationContext) -> str: return await context.app.thread_navigation.open( - owner_mode=context.owner_mode, project_path=context.project_path, target=self.name, + owner_mode=context.owner_mode, + project_path=context.project_path, + target=self.name, ) @@ -98,6 +121,7 @@ class HistoryRoute: @abstractmethod def history_kind(self) -> type[ConversationKind]: ... + class HistoryTarget(HistoryRoute, NavigationTarget): @classmethod def for_name(cls, name: str): @@ -105,14 +129,19 @@ def for_name(cls, name: str): @classmethod def for_conversation(cls, conversation: ConversationKind) -> HistoryTarget: - target, = (member for member in cls.members_with(HistoryRoute) - if member.history_kind is type(conversation)) + (target,) = ( + member + for member in cls.members_with(HistoryRoute) + if member.history_kind is type(conversation) + ) return target.for_name(conversation.target.name) async def open(self, context: NavigationContext) -> str: return await context.app.session_navigation.history( - owner_mode=context.owner_mode, project_path=context.project_path, - me=context.actor, conversation=self.history_kind(CommandTarget.named(self.name)), + owner_mode=context.owner_mode, + project_path=context.project_path, + me=context.actor, + conversation=self.history_kind(CommandTarget.named(self.name)), ) @@ -121,9 +150,14 @@ class ChannelLike: def menu_context(self, sidebar, **kwargs): from toad.target_commands import TargetContext - return TargetContext(sidebar.app, sidebar.model_state.observed.service, self.name, - sidebar.app.workspace_sessions.source.view.navigation_context.actor, - sidebar.app.project_dir) + + return TargetContext( + sidebar.app, + sidebar.model_state.observed.service, + self.name, + sidebar.app.workspace_sessions.source.view.navigation_context.actor, + sidebar.app.project_dir, + ) @dataclass(frozen=True) diff --git a/src/toad/project_path_owner.py b/src/toad/project_path_owner.py index 43be855e7..2f419c623 100644 --- a/src/toad/project_path_owner.py +++ b/src/toad/project_path_owner.py @@ -13,14 +13,20 @@ class ProjectPathOwner: @classmethod def containing(cls, widget: Widget) -> "ProjectPathOwner": - return next(owner for owner in widget.ancestors_with_self if isinstance(owner, cls)) + return next( + owner for owner in widget.ancestors_with_self if isinstance(owner, cls) + ) @property def reference_context(self): from agent_comms.display_references import ProjectReferences + root = self.coordination_root - return (ProjectReferences() if root is None else - self.app.coordination_access.reference_context(root)) + return ( + ProjectReferences() + if root is None + else self.app.coordination_access.reference_context(root) + ) @property def link_context_events(self): @@ -28,11 +34,15 @@ def link_context_events(self): def link_context_changed(self): from toad.core.events import LinkContextChanged + self.publish_core(LinkContextChanged()) def admits_link(self, widget: Widget, root: Path) -> bool: - return (widget.is_on_screen and widget.screen.is_current - and self.project_root.resolve() == root) + return ( + widget.is_on_screen + and widget.screen.is_current + and self.project_root.resolve() == root + ) @property @abstractmethod diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index df4d9f625..f7606fdd9 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -69,6 +69,7 @@ def __init__( self.project_path = project_path self.owner_mode = owner_mode from toad.thread_identity import SessionIdentity + self._identity = SessionIdentity.for_thread(me, owner_root) self.conversation = conversation self.wire_root = wire_root @@ -118,7 +119,9 @@ def compose(self) -> ComposeResult: content = ( (self.create_chat(),) if self._content_loaded - else (Static(f"Opening {self.conversation.target.name}…", id="comms-opening"),) + else ( + Static(f"Opening {self.conversation.target.name}…", id="comms-opening"), + ) ) yield containers.Center( ThreadSidebar( @@ -127,8 +130,12 @@ def compose(self) -> ComposeResult: CoordinationStatus(), id="coordination-panel", ), - SideBar.Panel("Comms", ThreadCommsSidebar(), - id="thread-comms-panel", header_control=RelationshipSort()), + SideBar.Panel( + "Comms", + ThreadCommsSidebar(), + id="thread-comms-panel", + header_control=RelationshipSort(), + ), state=self._thread_sidebar_state, ), containers.Vertical(*content, id="comms-content"), @@ -137,31 +144,43 @@ def compose(self) -> ComposeResult: def create_chat(self) -> CommsChatView: """One route-bound conversation factory for compose and lazy hydration.""" return CommsChatView( - self.project_path, me=self.thread_identity.thread_name(), conversation=self.conversation, + self.project_path, + me=self.thread_identity.thread_name(), + conversation=self.conversation, wire_root=self.wire_root, ) - @handles(SelectHistoricalIdentity) async def select_historical_identity(self, event: CoreEventMessage) -> None: event.stop() from toad.screens.historical_sessions import HistoricalSessions + comms = self.app.coordination_access.observation.observed().service - threads = await asyncio.to_thread(comms.views.historical_threads, event.event.name) + threads = await asyncio.to_thread( + comms.views.historical_threads, event.event.name + ) if not threads: self.notify("This sender has no preserved identity declaration.") return - self.app.push_screen(HistoricalSessions(comms, threads, name=event.event.name, source=event.event.source)) + self.app.push_screen( + HistoricalSessions( + comms, threads, name=event.event.name, source=event.event.source + ) + ) async def action_historical_sessions(self) -> None: from toad.screens.historical_sessions import HistoricalSessions + comms = self.app.coordination_access.observation.observed().service threads = await asyncio.to_thread(comms.views.historical_threads) if not threads: self.notify("No preserved history sources are attached yet.") return - self.app.push_screen(self.conversation.historical_sessions( - partial(HistoricalSessions, comms, threads))) + self.app.push_screen( + self.conversation.historical_sessions( + partial(HistoricalSessions, comms, threads) + ) + ) def on_mount(self) -> None: if not self._content_loaded: @@ -245,7 +264,12 @@ def on_side_bar_dismiss(self, event: SideBar.Dismiss) -> None: @property def navigation_context(self) -> NavigationContext: - return NavigationContext(self.app, self.owner_mode, self.project_path, self.thread_identity.thread_name()) + return NavigationContext( + self.app, + self.owner_mode, + self.project_path, + self.thread_identity.thread_name(), + ) @handles(SelectTarget) async def on_select_target(self, event: CoreEventMessage) -> None: @@ -261,10 +285,14 @@ async def open_direct_messages(self) -> None: await self.conversation.toggle_dm(self) def action_session_previous(self) -> None: - self.app.session_navigation.events.publish(session_requests.SessionNavigate(self.owner_mode, -1)) + self.app.session_navigation.events.publish( + session_requests.SessionNavigate(self.owner_mode, -1) + ) def action_session_next(self) -> None: - self.app.session_navigation.events.publish(session_requests.SessionNavigate(self.owner_mode, +1)) + self.app.session_navigation.events.publish( + session_requests.SessionNavigate(self.owner_mode, +1) + ) async def action_close_session(self) -> None: if self.id is not None: diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 6135a4cfb..aa09eb404 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -53,7 +53,12 @@ from toad.widgets.project_panel import ProjectPanel, ProjectSearchButton from toad.widgets.side_bar import SideBar, SideBarCollapsible from toad.widgets.channels_sidebar import ChannelsSidebar -from toad.navigation_target import FeedTarget, DirectTarget, NavigationTarget, ThreadTarget +from toad.navigation_target import ( + FeedTarget, + DirectTarget, + NavigationTarget, + ThreadTarget, +) class SessionCommands(Provider): @@ -79,16 +84,24 @@ async def discover(self) -> Hits: class ModeProvider(SessionCommands): def commands(self, session: MainScreen) -> Iterable[SimpleCommand]: - for mode in sorted(session.conversation.agent_binding.available_modes, - key=lambda mode: mode.name): - yield SimpleCommand(mode.name, partial(session.conversation.set_mode, mode.value), - mode.description) + for mode in sorted( + session.conversation.agent_binding.available_modes, + key=lambda mode: mode.name, + ): + yield SimpleCommand( + mode.name, + partial(session.conversation.set_mode, mode.value), + mode.description, + ) class MCPInventoryProvider(SessionCommands): def commands(self, session: MainScreen) -> Iterable[SimpleCommand]: - yield SimpleCommand("Pi MCP inventory", session.action_mcp_inventory, - "Read-only package snapshot") + yield SimpleCommand( + "Pi MCP inventory", + session.action_mcp_inventory, + "Read-only package snapshot", + ) class ScreenContent(DeclaredFamily, affix="Content"): @@ -223,6 +236,7 @@ class MainScreen(ViewSessionOrigin, SessionView, can_focus=False): busy_count = var(0) conversation = getters.query_one(Conversation) thread_sidebar = getters.query_one(SessionThreadSidebar) + @property def side_bar(self): return self.screen.query_one(ChannelsSidebar) @@ -272,14 +286,18 @@ def acknowledge(cursor) -> None: def mark() -> None: resolved = identity.resolve_thread(comms.registry, project) - comms.views.mark_thread_view_read(resolved, worktree=worktree, through=cursor) + comms.views.mark_thread_view_read( + resolved, worktree=worktree, through=cursor + ) access.write(selected, mark) async def mark(): try: await conversation.transcript.mark_read( - through, acknowledge, lambda: app.selected_session is self, + through, + acknowledge, + lambda: app.selected_session is self, ) except DMScopeChanged as error: app.show_error(error, "DM read not marked") @@ -287,8 +305,9 @@ async def mark(): return ReadWork(mark()) @classmethod - def from_thread(cls, project: Path, root: str, target, - agent: AgentDefinition | None = None) -> "MainScreen": + def from_thread( + cls, project: Path, root: str, target, agent: AgentDefinition | None = None + ) -> "MainScreen": screen = cls(project, agent, agent_session_id=target.name) screen.bind_thread(target, root) return screen @@ -299,13 +318,18 @@ def bind_thread(self, owner, root: str) -> None: def identity_changed(self, previous) -> None: if self.id is not None: - self.app.session_tracker.bind_identity(self.id, previous.thread_name(), self.thread_identity.thread_name()) + self.app.session_tracker.bind_identity( + self.id, previous.thread_name(), self.thread_identity.thread_name() + ) @classmethod def opening(cls, request, origin, app) -> "MainScreen": # The request has only a name; bind the wire after its metadata read. - screen = cls(request.project, origin.source.agent_for_opening(app.agent_data), - agent_session_id=request.target) + screen = cls( + request.project, + origin.source.agent_for_opening(app.agent_data), + agent_session_id=request.target, + ) screen.content = NavigatedOpeningContent() return screen @@ -336,7 +360,9 @@ async def retire_presentation(self) -> None: await self.presentation.retire(self) def retained_native_presentations(self): - resources = [(self, self.presentation)] if self.presentation.widget is not None else [] + resources = ( + [(self, self.presentation)] if self.presentation.widget is not None else [] + ) sidebar = self.thread_sidebar if sidebar.panels_loaded: resources.append((self, sidebar)) @@ -348,9 +374,13 @@ def session_disposition(self) -> SessionDisposition: def make_conversation(self) -> Conversation: return 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.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, ) @property @@ -426,7 +456,9 @@ async def on_coordination_update(self, event: CoordinationChangedUpdate) -> None if event.worktree is not None: self.bind_project(Path(event.worktree)) if self.id is not None: - self.app.session_navigation.sync_owner_root(self.id, self.coordination_root) + self.app.session_navigation.sync_owner_root( + self.id, self.coordination_root + ) await self.presentation.apply_coordination(self, event) @handles(core_events.AgentReady) @@ -445,7 +477,9 @@ def navigation_target(self) -> NavigationTarget: @property def navigation_context(self) -> NavigationContext: assert self.id is not None - return NavigationContext(self.app, self.id, self.project_path, self.thread_identity.thread_name()) + return NavigationContext( + self.app, self.id, self.project_path, self.thread_identity.thread_name() + ) def remember_direct_target(self, target: str) -> None: self._last_dm_target = target @@ -467,8 +501,10 @@ async def open_direct_messages(self) -> None: """Open the last-selected DM as a native Toad session.""" target = self._last_dm_target if not target: - self.notify("Choose Direct messages from an agent's context menu", - title="Direct messages") + self.notify( + "Choose Direct messages from an agent's context menu", + title="Direct messages", + ) return if target and self.id is not None: await self.open_sidebar_target(DirectTarget(target)) @@ -480,11 +516,15 @@ async def on_comms_select_target(self, event: CoreEventMessage) -> None: def action_session_previous(self) -> None: if self.id is not None: - self.app.session_navigation.events.publish(session_requests.SessionNavigate(self.id, -1)) + self.app.session_navigation.events.publish( + session_requests.SessionNavigate(self.id, -1) + ) def action_session_next(self) -> None: if self.id is not None: - self.app.session_navigation.events.publish(session_requests.SessionNavigate(self.id, +1)) + self.app.session_navigation.events.publish( + session_requests.SessionNavigate(self.id, +1) + ) @handles(input_events.ProjectDirectoryUpdated) async def on_project_directory_update(self) -> None: @@ -541,14 +581,15 @@ def on_session_thread_title(self, message: CoreEventMessage) -> None: @handles(core_events.SessionSubtitleChanged) def on_session_subtitle_changed(self, message: CoreEventMessage) -> None: if self.id is not None: - self.app.session_tracker.update_session(self.id, subtitle=message.event.subtitle) + self.app.session_tracker.update_session( + self.id, subtitle=message.event.subtitle + ) @handles(core_events.SessionPathChanged) def on_session_path_changed(self, message: CoreEventMessage) -> None: if self.id is not None: self.app.session_tracker.update_session(self.id, path=message.event.path) - def on_mount(self) -> None: self.observe_core(self.app.events) self.observe_core(self.app.coordination_access.events) diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 214f982e5..fd8bf96e9 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -1,4 +1,5 @@ """Logical session surfaces hosted by the one native WorkspaceScreen.""" + from textual.containers import Vertical from textual.reactive import reactive from pathlib import Path @@ -10,13 +11,19 @@ from toad.thread_identity import SessionIdentity, NoThreadIdentity from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope from toad.navigation_target import NavigationTarget, NoTarget -from toad.widgets.message_notifications import NothingToRead, PaintedRead, VisibleReadMarking +from toad.widgets.message_notifications import ( + NothingToRead, + PaintedRead, + VisibleReadMarking, +) + class SessionView(VisibleReadMarking, CoreEventReceiver, Vertical, SessionScope): title = reactive("") sub_title = reactive("") DEFAULT_CSS = "SessionView { width: 1fr; height: 1fr; }" + def __init__(self, *args, **kwargs): self._identity: SessionIdentity = NoThreadIdentity() super().__init__(*args, **kwargs) @@ -25,7 +32,9 @@ def __init__(self, *args, **kwargs): def display_name(self) -> str: """Borrow this view's current label from the same projection as its tab.""" sessions = self.app.session_navigation - return self.app.coordination_access.observation.tab(sessions.get(self.id), sessions).title + return self.app.coordination_access.observation.tab( + sessions.get(self.id), sessions + ).title @property def project_root(self) -> Path: @@ -51,6 +60,7 @@ def navigation_target(self) -> NavigationTarget: def belongs_to_wire(self, root) -> bool: """Unbound views may project the selected wire; bound views stay on it.""" from pathlib import Path + source = self.coordination_root return source is None or Path(source).expanduser().resolve() == root @@ -66,7 +76,9 @@ def on_transcript_source_work_finished(self, message: CoreEventMessage) -> None: async def open_direct_messages(self) -> None: """Open this session's direct-message destination when it has one.""" - self.notify("Direct messages are unavailable from this view", title="Direct messages") + self.notify( + "Direct messages are unavailable from this view", title="Direct messages" + ) def sidebars(self): """Supply this view's native sidebar resources through its declaration.""" @@ -133,4 +145,3 @@ async def close_presentation(self) -> None: def retained_native_presentations(self): """Declare accepted native trees; non-native views own no such trees.""" return () - diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index b34d687ba..3559265a6 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -27,7 +27,13 @@ from toad.session_scope import ProjectSessionOrigin from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction -from toad.widgets.sidebar_tree import RowReconciliation, SidebarChange, SidebarGroup, SidebarModelView, TargetTree +from toad.widgets.sidebar_tree import ( + RowReconciliation, + SidebarChange, + SidebarGroup, + SidebarModelView, + TargetTree, +) from toad.widgets.splash_text import SplashText if TYPE_CHECKING: @@ -37,14 +43,16 @@ class WelcomeTitle(SplashText): """The app's name, swept into the theme's colours each time the view shows.""" - TEXT = "\n".join(( - " ██████╗ ██████╗ ███╗ ███╗███╗ ███╗███████╗", - "██╔════╝██╔═══██╗████╗ ████║████╗ ████║██╔════╝", - "██║ ██║ ██║██╔████╔██║██╔████╔██║███████╗", - "██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║╚════██║", - "╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║███████║", - " ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝", - )) + TEXT = "\n".join( + ( + " ██████╗ ██████╗ ███╗ ███╗███╗ ███╗███████╗", + "██╔════╝██╔═══██╗████╗ ████║████╗ ████║██╔════╝", + "██║ ██║ ██║██╔████╔██║██╔████╔██║███████╗", + "██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║╚════██║", + "╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║███████║", + " ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝", + ) + ) DEFAULT_CSS = """ WelcomeTitle { width: auto; height: auto; color: $text-muted; text-wrap: nowrap; } @@ -87,7 +95,7 @@ def keys(self, model: SidebarModel) -> tuple[str, ...]: @property def member_keys(self): model = self.query_ancestor(WelcomeEntries).model_state.shown - return self.keys(model)[:self.LIMIT] + return self.keys(model)[: self.LIMIT] def sync_members(self, changed: frozenset[str]) -> None: if not self.member_container.is_attached: @@ -108,9 +116,15 @@ def new_row(self, key): return CommsRow(channel_target(key), key) def show_member(self, key, row): - channel = self.query_ancestor(WelcomeEntries).model_state.shown.channels.rows[key] + channel = self.query_ancestor(WelcomeEntries).model_state.shown.channels.rows[ + key + ] row.set_label( - f"{channel.label} {channel.unread.label}" if channel.unread.label else channel.label, + ( + f"{channel.label} {channel.unread.label}" + if channel.unread.label + else channel.label + ), ) row.tooltip = channel.tooltip row.refresh_paint() @@ -123,8 +137,11 @@ class WelcomeThreads(WelcomeSection): def keys(self, model): listed = model.channels.rows.get(ALL_COMMS_TARGET) - return tuple(name for name in (listed.members if listed is not None else ()) - if name in model.threads.rows) + return tuple( + name + for name in (listed.members if listed is not None else ()) + if name in model.threads.rows + ) def new_row(self, key): return ThreadStatusRow(ThreadTarget(key), key) @@ -183,8 +200,10 @@ class WelcomeScreen(ProjectSessionOrigin, SessionView, can_focus=False): WelcomeScreen .hints { color: $text-muted; margin: 1 0 0 0; } """ - HINTS = ("[b]n[/b] new session [b]ctrl+g[/b] channel view [b]ctrl+b[/b] sidebar\n" - "[b]↑ ↓[/b] choose [b]enter[/b] open") + HINTS = ( + "[b]n[/b] new session [b]ctrl+g[/b] channel view [b]ctrl+b[/b] sidebar\n" + "[b]↑ ↓[/b] choose [b]enter[/b] open" + ) def __init__(self) -> None: super().__init__() @@ -196,22 +215,32 @@ def compose(self) -> ComposeResult: agent = f"{selected.name} · " if selected is not None else "" with VerticalScroll(can_focus=False): yield WelcomeTitle() - yield Static(f"{agent}{format_path(self.project_path)}", classes="subtitle", markup=False) + yield Static( + f"{agent}{format_path(self.project_path)}", + classes="subtitle", + markup=False, + ) yield WelcomeEntries() yield Static(self.HINTS, classes="hints") @property def navigation_context(self) -> NavigationContext: assert self.id is not None - return NavigationContext(self.app, self.id, self.project_path, self.thread_identity.thread_name()) + return NavigationContext( + self.app, self.id, self.project_path, self.thread_identity.thread_name() + ) async def open_sidebar_target(self, target: NavigationTarget) -> str: app = cast("ToadApp", self.app) project = str(self.project_path) from toad.thread_identity import ThreadIdentity + def identity(): service = app.coordination_access.observation.observed().service - return ThreadIdentity(service.messaging.user_identity(project).incarnation, str(service.root)) + return ThreadIdentity( + service.messaging.user_identity(project).incarnation, str(service.root) + ) + with self.changing_identity(): self._identity = await app.preparation.run_thread(identity) return await super().open_sidebar_target(target) diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index e2b320a16..cfc984350 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from toad.app import ToadApp + @dataclass(frozen=True) class WorkspaceLayoutSnapshot: """One native layout's size, style and invalidation proof.""" @@ -35,14 +36,21 @@ class WorkspaceLayoutSnapshot: @classmethod def capture(cls, screen, *, include_scroll=True): - return cls(screen.is_attached, screen.stack_updates, screen._size, - screen._layout_required, screen._scroll_required if include_scroll else False, - frozenset(screen._layout_widgets), screen._geometry_revision) + return cls( + screen.is_attached, + screen.stack_updates, + screen._size, + screen._layout_required, + screen._scroll_required if include_scroll else False, + frozenset(screen._layout_widgets), + screen._geometry_revision, + ) @classmethod def ready(cls, screen, size): - return cls(True, True, size, False, False, frozenset(), - screen._geometry_revision) + return cls( + True, True, size, False, False, frozenset(), screen._geometry_revision + ) class WorkspaceLayout(DeclaredFamily, affix="WorkspaceLayout"): @@ -63,7 +71,10 @@ def reusable(self, screen, size, *, include_scroll=True): requested = WorkspaceLayoutSnapshot.ready(screen, size) if self.proof != requested: return False - return WorkspaceLayoutSnapshot.capture(screen, include_scroll=include_scroll) == requested + return ( + WorkspaceLayoutSnapshot.capture(screen, include_scroll=include_scroll) + == requested + ) class WorkspaceScreen(WidgetView, Screen): @@ -72,7 +83,9 @@ class WorkspaceScreen(WidgetView, Screen): BINDINGS = [ Binding("ctrl+b,f20", "show_sidebar", "Sidebar"), - Binding("escape", "back_to_bottom", "Back to bottom", priority=True, show=False), + Binding( + "escape", "back_to_bottom", "Back to bottom", priority=True, show=False + ), ] @classmethod @@ -94,6 +107,7 @@ def coordination_root(self): def compose(self): from textual.containers import Horizontal, Container + chrome = self.app.workspace_chrome yield chrome.navigation with Horizontal(id="workspace-body").set_styles(layout=chrome): @@ -116,15 +130,23 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No sidebar = self.app.workspace_chrome.channels return sidebar.display and not sidebar.has_focus_within if action == "back_to_bottom": - return (not (self.focused is not None and self.focused.uses_screen_coordinates) - and self.back_to_bottom_window is not None) + return ( + not (self.focused is not None and self.focused.uses_screen_coordinates) + and self.back_to_bottom_window is not None + ) return super().check_action(action, parameters) @property def back_to_bottom_window(self): published = self._compositor.published_widgets - return next((window for window in self.history_windows - if window in published and window.can_jump_to_latest), None) + return next( + ( + window + for window in self.history_windows + if window in published and window.can_jump_to_latest + ), + None, + ) def action_back_to_bottom(self) -> None: if (window := self.back_to_bottom_window) is not None: @@ -140,9 +162,11 @@ def action_show_sidebar(self) -> None: # tab's rendered line/segment graph in the cyclic collector's live heap. _navigation_layout: WorkspaceLayout = PendingWorkspaceLayout() + @cached_property def frame_presentation(self): from toad.frame_presentation import FramePresentation + return FramePresentation(self) @cached_property @@ -153,8 +177,11 @@ def history_windows(self) -> set: def _layout_geometry_targets(self) -> tuple[Widget, ...]: # Reader positions and restorations compensate against these widgets, # so viewport layout places them even when they are off screen. - return tuple(target for window in self.history_windows - for target in window.history_geometry_targets()) + return tuple( + target + for window in self.history_windows + for target in window.history_geometry_targets() + ) def on_screen_suspend(self) -> None: self.frame_presentation.suspend() @@ -183,9 +210,14 @@ def release_frame_callback(self, owner, callback) -> bool: def _use_viewport_layout(self) -> bool: return self.is_current - def _layout_steps(self, size: Size | None = None, scroll: bool = False, deadline=None): - changes = tuple((change, change.layout_requests) for window in self.history_windows - if (change := window.history_change) is not None) + def _layout_steps( + self, size: Size | None = None, scroll: bool = False, deadline=None + ): + changes = tuple( + (change, change.layout_requests) + for window in self.history_windows + if (change := window.history_change) is not None + ) # Capture the old rows before any descendant measures its new width; # that layout and reader placement belong to one unpublished frame. with self.app.batch_update(): @@ -193,8 +225,13 @@ def _layout_steps(self, size: Size | None = None, scroll: bool = False, deadline if window.shown: window.keep_reader() yield from super()._layout_steps(size, scroll, deadline) - if any([window.place_reader() for window in self.history_windows - if window.reader_for_layout() is not None]): + if any( + [ + window.place_reader() + for window in self.history_windows + if window.reader_for_layout() is not None + ] + ): yield from super()._layout_steps(size, scroll=True) for change, answered in changes: change.finish_layout(answered) @@ -240,7 +277,8 @@ def get_widget_and_offset_at(self, x: int, y: int): # the compositor commits its next map. A stale hit is not a valid new # selection endpoint (Textual otherwise asserts that it has a parent). if widget is not None and ( - not widget.is_attached or (offset is not None and not isinstance(widget.parent, Widget)) + not widget.is_attached + or (offset is not None and not isinstance(widget.parent, Widget)) ): return None, None return widget, offset @@ -269,7 +307,9 @@ async def layout_navigation(self) -> None: await self.layout_in_steps(self.app.size) if roster.navigation.restore_scroll(): self._refresh_layout(self.app.size, scroll=True) - self._navigation_layout = MeasuredWorkspaceLayout(WorkspaceLayoutSnapshot.ready(self, self.app.size)) + self._navigation_layout = MeasuredWorkspaceLayout( + WorkspaceLayoutSnapshot.ready(self, self.app.size) + ) def _on_screen_resume(self, event: ScreenResume) -> None: self.frame_presentation.resume() diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index b2363be1c..47beef67a 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -3,6 +3,7 @@ Entries are the callable factories in WorkspaceSessions, not another roster. The mounted view remains the authority for source/presentation state. """ + from __future__ import annotations from abc import abstractmethod @@ -18,7 +19,13 @@ from agent_comms.ui_model.sidebar import ThreadRowModel -from toad.session_tracker import CommsViewKey, OpenTab, NamedTab, ThreadTab, SessionDetails +from toad.session_tracker import ( + CommsViewKey, + OpenTab, + NamedTab, + ThreadTab, + SessionDetails, +) from agent_comms.ui_model.unread import UnobservedUnread if TYPE_CHECKING: @@ -51,20 +58,26 @@ def address(self) -> object: ... async def ready(self, sessions: SessionNavigation) -> None: """Views with deferred loading own their loading completion.""" - async def reconnect(self, sessions: SessionNavigation, selected, snapshot, targets) -> None: + async def reconnect( + self, sessions: SessionNavigation, selected, snapshot, targets + ) -> None: """Only a bound native entry can refresh its connection resource.""" def source(self, sessions: SessionNavigation) -> MainScreen | None: return None - def thread_row(self, sessions: SessionNavigation, row: ThreadRowModel) -> ThreadRowModel: + def thread_row( + self, sessions: SessionNavigation, row: ThreadRowModel + ) -> ThreadRowModel: """How this entry's own session changes a thread's row; most entries hold none.""" return row def depends_on(self, mode: str) -> bool: return self.mode == mode - def original_threads(self, sessions: SessionNavigation) -> tuple[tuple[str, ThreadIncarnation], ...]: + def original_threads( + self, sessions: SessionNavigation + ) -> tuple[tuple[str, ThreadIncarnation], ...]: return () def original_channels(self) -> tuple[tuple[str, str], ...]: @@ -81,16 +94,24 @@ def forget(self, sessions: SessionNavigation) -> None: if view := sessions.app.workspace_sessions.views.get(self.mode): view.close_core_events() - def sync_owner_root(self, sessions: SessionNavigation, owner: str, root: str | None) -> None: + def sync_owner_root( + self, sessions: SessionNavigation, owner: str, root: str | None + ) -> None: pass - def sync_project(self, sessions: SessionNavigation, owner: str, project: Path) -> None: + def sync_project( + self, sessions: SessionNavigation, owner: str, project: Path + ) -> None: pass class NativeSessionEntry(SessionEntry): - def __init__(self, details: SessionDetails, factory: Callable[[], MainScreen], - original: tuple[str, ThreadIncarnation] | None = None) -> None: + def __init__( + self, + details: SessionDetails, + factory: Callable[[], MainScreen], + original: tuple[str, ThreadIncarnation] | None = None, + ) -> None: super().__init__(details.mode_name) self.details = details self.factory = factory @@ -100,7 +121,9 @@ def original_threads(self, sessions): source = self.source(sessions) if source is None: return (self.original,) if self.original is not None else () - return source.thread_identity.transcript_scope().original_threads(source.thread_identity) + return source.thread_identity.transcript_scope().original_threads( + source.thread_identity + ) def __call__(self) -> MainScreen: source = self.factory() @@ -117,7 +140,10 @@ def source(self, sessions: SessionNavigation) -> MainScreen | None: def thread_row(self, sessions, row): """The session this tab opened shows its state on that thread's row.""" source = self.source(sessions) - if source is None or all(identity != row.incarnation for _, identity in self.original_threads(sessions)): + if source is None or all( + identity != row.incarnation + for _, identity in self.original_threads(sessions) + ): return row return source.session_disposition.present(row, row.disconnected) @@ -127,10 +153,18 @@ async def reconnect(self, sessions, selected, snapshot, targets): source = self.source(sessions) if source is None: return - original = next((identity for root, identity in self.original_threads(sessions) - if Path(root).resolve() == selected.root - and any(identity.matches_recorded_name(target, snapshot) - for target in targets)), None) + original = next( + ( + identity + for root, identity in self.original_threads(sessions) + if Path(root).resolve() == selected.root + and any( + identity.matches_recorded_name(target, snapshot) + for target in targets + ) + ), + None, + ) if original is None: return thread = snapshot.require(snapshot.canonical_name(original.name)) @@ -149,20 +183,32 @@ def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: return self.unobserved_tab() row = observed.sidebar.threads.rows.get(source.thread_identity.thread_name()) originals = self.original_threads(sessions) - if row is not None and originals and not any( + if ( + row is not None + and originals + and not any( observed.observes(root) and identity == row.incarnation - for root, identity in originals): + for root, identity in originals + ) + ): return self.unobserved_tab() if row is not None: - return ThreadTab(self.mode, source.session_disposition.present(row, row.disconnected)) + return ThreadTab( + self.mode, source.session_disposition.present(row, row.disconnected) + ) return self.unobserved_tab() async def return_to(self, sessions: SessionNavigation) -> None: app = sessions.app # Another session is preferred to a file or history tab between them. - remaining = [details.mode_name for details in app.session_tracker.ordered_sessions - if details.mode_name != self.mode] - await app.select_session(app.tab_order.previous(self.mode, eligible=remaining or None)) + remaining = [ + details.mode_name + for details in app.session_tracker.ordered_sessions + if details.mode_name != self.mode + ] + await app.select_session( + app.tab_order.previous(self.mode, eligible=remaining or None) + ) def forget(self, sessions: SessionNavigation) -> None: super().forget(sessions) @@ -170,8 +216,15 @@ def forget(self, sessions: SessionNavigation) -> None: class HistorySessionEntry(SessionEntry): - def __init__(self, mode: str, key: CommsViewKey, project: Path, - owner_root: str | None, participants: tuple[ThreadIncarnation, ...], owner) -> None: + def __init__( + self, + mode: str, + key: CommsViewKey, + project: Path, + owner_root: str | None, + participants: tuple[ThreadIncarnation, ...], + owner, + ) -> None: super().__init__(mode) self.key, self.project, self.owner_root = key, project, owner_root self.participants, self.owner = participants, owner @@ -188,11 +241,16 @@ def original_channels(self): def __call__(self): from toad.screens.comms import CommsScreen + key = self.key - return CommsScreen(project_path=self.project, owner_mode=key.owner_mode, - me=self.owner.thread_identity.thread_name(), - conversation=key.conversation, - owner_root=self.owner_root, wire_root=key.root) + return CommsScreen( + project_path=self.project, + owner_mode=key.owner_mode, + me=self.owner.thread_identity.thread_name(), + conversation=key.conversation, + owner_root=self.owner_root, + wire_root=key.root, + ) @property def address(self) -> object: @@ -208,8 +266,11 @@ def unobserved_tab(self) -> OpenTab: return NamedTab(self.mode, self.key.title) def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: - unread = (self.key.conversation.unread(observed.sidebar.observation) - if observed.observes(self.key.root) else UnobservedUnread()) + unread = ( + self.key.conversation.unread(observed.sidebar.observation) + if observed.observes(self.key.root) + else UnobservedUnread() + ) return NamedTab(self.mode, self.key.title, unread) def depends_on(self, mode: str) -> bool: @@ -217,16 +278,28 @@ def depends_on(self, mode: str) -> bool: async def return_to(self, sessions: SessionNavigation) -> None: app, owner = sessions.app, self.key.owner_mode - await app.select_session(owner if sessions.get(owner) is not None - else app.tab_order.previous(self.mode)) - - def sync_owner_root(self, sessions: SessionNavigation, owner: str, root: str | None) -> None: - self.owner_root = root if root is not None and Path(root).expanduser().resolve() == Path(self.key.root) else None + await app.select_session( + owner + if sessions.get(owner) is not None + else app.tab_order.previous(self.mode) + ) + + def sync_owner_root( + self, sessions: SessionNavigation, owner: str, root: str | None + ) -> None: + self.owner_root = ( + root + if root is not None + and Path(root).expanduser().resolve() == Path(self.key.root) + else None + ) if view := sessions.app.workspace_sessions.views.get(self.mode): view.rebind_identity(self.owner.thread_identity.thread_name()) view.rebind_owner_root(self.owner_root) - def sync_project(self, sessions: SessionNavigation, owner: str, project: Path) -> None: + def sync_project( + self, sessions: SessionNavigation, owner: str, project: Path + ) -> None: self.project = project if view := sessions.app.workspace_sessions.views.get(self.mode): view.rebind_project(project) @@ -243,14 +316,18 @@ def __init__(self, mode: str, path: Path, origin: str) -> None: def of(cls, mode: str, path: Path, origin: str) -> PreviewSessionEntry: from toad.acp.agent_log import AgentLog - return (AcpLogSessionEntry if AgentLog.writes(path) else cls)(mode, path, origin) + return (AcpLogSessionEntry if AgentLog.writes(path) else cls)( + mode, path, origin + ) def __call__(self): from toad.screens.file_preview import FilePreviewScreen + return FilePreviewScreen(self.path, self.content) def content(self): from toad.widgets.project_panel import FilePreview + return FilePreview(self.path, id="file-preview") @property @@ -270,7 +347,11 @@ def entered(self, previous: str) -> None: async def return_to(self, sessions: SessionNavigation) -> None: app = sessions.app - target = self.origin if sessions.get(self.origin) is not None else app.tab_order.previous(self.mode) + target = ( + self.origin + if sessions.get(self.origin) is not None + else app.tab_order.previous(self.mode) + ) await app.select_session(target) @@ -279,6 +360,7 @@ class AcpLogSessionEntry(PreviewSessionEntry): def content(self): from toad.widgets.acp_log import AcpLogPreview + return AcpLogPreview(self.path, id="file-preview") @property diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index a3b1afe7d..b6279891f 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -1,4 +1,5 @@ """Opening and closing sessions use the existing workspace's declared factories.""" + from __future__ import annotations import asyncio from toad.core import events as core_events @@ -12,12 +13,20 @@ from agent_comms.presentation import NoRevision, ReadState from agent_comms.revision_views import RegistrationsView -from agent_comms.errors import RelationViolationError, UnknownChannelError, UnregisteredThreadError +from agent_comms.errors import ( + RelationViolationError, + UnknownChannelError, + UnregisteredThreadError, +) from agent_comms.route_selection import RouteChanged, root_is_current from agent_comms.thread_identity import ThreadIncarnation from toad.navigation_preparation import CommsNavigationRequest -from toad.session_entry import (HistorySessionEntry, NativeSessionEntry, - PreviewSessionEntry, SessionEntry) +from toad.session_entry import ( + HistorySessionEntry, + NativeSessionEntry, + PreviewSessionEntry, + SessionEntry, +) from toad.session_tracker import OpenTab, SessionDetails if TYPE_CHECKING: @@ -46,18 +55,25 @@ def direct_messages(self, target: str | None = None): source = self.app.selected_session if source is None: raise ValueError("Direct messages require an open session") - operation = (source.open_direct_messages() if target is None - else source.open_sidebar_target(DirectTarget(target))) + operation = ( + source.open_direct_messages() + if target is None + else source.open_sidebar_target(DirectTarget(target)) + ) return self.app.run_worker(operation, group="direct-messages") def bind_events(self) -> None: self.app.subscribe_core(self.events) - self.app.observe_core_callback(self.app.coordination_access.events, - core_events.CoordinationObserved, self.observed) + self.app.observe_core_callback( + self.app.coordination_access.events, + core_events.CoordinationObserved, + self.observed, + ) async def reveal(self) -> None: from toad.widgets.comms_sidebar import CommsSidebar from toad.widgets.side_bar import SideBar, SideBarCollapsible + app = self.app sidebar = app.screen.query_one_optional(CommsSidebar) if sidebar is None: @@ -74,17 +90,24 @@ async def reveal(self) -> None: @property def members(self) -> tuple[SessionEntry, ...]: - return tuple(entry for group in self.entries.values() for entry in group.values()) + return tuple( + entry for group in self.entries.values() for entry in group.values() + ) @property def processes(self): """Borrow connections from actual native sources; keep no second roster.""" - return tuple(process for member in self.members - if (source := member.source(self)) is not None - for process in source.presentation.operational_binding.borrowed_processes()) + return tuple( + process + for member in self.members + if (source := member.source(self)) is not None + for process in source.presentation.operational_binding.borrowed_processes() + ) def get(self, mode: str) -> SessionEntry | None: - return next((group[mode] for group in self.entries.values() if mode in group), None) + return next( + (group[mode] for group in self.entries.values() if mode in group), None + ) def find(self, address: object) -> SessionEntry | None: return next((entry for entry in self.members if entry.address == address), None) @@ -104,12 +127,21 @@ async def reconnect(self, selected, targets: tuple[str, ...]) -> None: return originals = self.members snapshot = await self.app.preparation.run_thread( - lambda: self.app.coordination_access.require(selected).service.registry.snapshot()) + lambda: self.app.coordination_access.require( + selected + ).service.registry.snapshot() + ) for member in originals: await member.reconnect(self, selected, snapshot, targets) - def bind_opened(self, view: MainScreen, *, original: tuple[str, ThreadIncarnation], - name: str, project: Path) -> None: + def bind_opened( + self, + view: MainScreen, + *, + original: tuple[str, ThreadIncarnation], + name: str, + project: Path, + ) -> None: """Bind the accepted source facts before preparing its native resources.""" entry = cast(NativeSessionEntry, self.get(view.id)) view.bind_project(project) @@ -125,9 +157,20 @@ def publish(self) -> None: def views_retired(self, root, retired) -> None: """Close the open views on ``root`` whose sources the observation service found retired.""" root = str(root) - removed = tuple(member.mode for member in self.members if ( - any(owner == root and identity in retired.threads for owner, identity in member.original_threads(self)) - or any(owner == root and target in retired.channels for owner, target in member.original_channels()))) + removed = tuple( + member.mode + for member in self.members + if ( + any( + owner == root and identity in retired.threads + for owner, identity in member.original_threads(self) + ) + or any( + owner == root and target in retired.channels + for owner, target in member.original_channels() + ) + ) + ) if removed: task = asyncio.ensure_future(self.close_many(removed)) self._closing_retired.add(task) @@ -144,14 +187,26 @@ async def open(self, entry: SessionEntry, *, after: str | None = None) -> str: await entry.ready(self) return entry.mode - async def new(self, factory: Callable[[], MainScreen], *, title: str = "New Session", original=None) -> SessionDetails: + async def new( + self, + factory: Callable[[], MainScreen], + *, + title: str = "New Session", + original=None, + ) -> SessionDetails: details = self.app.session_tracker.new_session(title=title) await self.open(NativeSessionEntry(details, factory, original)) return details - async def launch(self, agent_identity: str, *, agent_session_id: str | None = None, - session_pk: int | None = None, project_path: Path | None = None, - initial_prompt: str | None = None) -> None: + async def launch( + self, + agent_identity: str, + *, + agent_session_id: str | None = None, + session_pk: int | None = None, + project_path: Path | None = None, + initial_prompt: str | None = None, + ) -> None: from toad.agents import read_agents from toad.db import DB from toad.screens.main import MainScreen @@ -171,33 +226,59 @@ async def launch(self, agent_identity: str, *, agent_session_id: str | None = No return project = project_path if project_path is not None else app.project_dir if agent_session_id is not None: - existing = next((identity for identity, origin in app.workspace_sessions.native_origins.items() - if origin.matches_launch(agent_identity, agent_session_id)), None) + existing = next( + ( + identity + for identity, origin in app.workspace_sessions.native_origins.items() + if origin.matches_launch(agent_identity, agent_session_id) + ), + None, + ) if existing is not None: await app.select_session(existing) return - await self.new(lambda: MainScreen(project, agent, agent_session_id, - session_pk=session_pk, initial_prompt=initial_prompt), - title=title or "New Session") - - async def history(self, *, owner_mode: str, project_path: Path, me: str, - conversation: ConversationKind) -> str: + await self.new( + lambda: MainScreen( + project, + agent, + agent_session_id, + session_pk=session_pk, + initial_prompt=initial_prompt, + ), + title=title or "New Session", + ) + + async def history( + self, + *, + owner_mode: str, + project_path: Path, + me: str, + conversation: ConversationKind, + ) -> str: from toad.thread_navigation import ThreadOrigin app = self.app try: source = app.workspace_sessions.origin(owner_mode).require() except RelationViolationError: - app.show_error("The owning agent tab was closed", "Comms target unavailable") + app.show_error( + "The owning agent tab was closed", "Comms target unavailable" + ) return app.selected_mode origin = ThreadOrigin.capture(app, source) try: - comms = await app.preparation.run_thread(lambda: app.coordination_access.observation.observed().service) + comms = await app.preparation.run_thread( + lambda: app.coordination_access.observation.observed().service + ) requested_root = str(comms.root) if not origin.current(app.thread_navigation, owner_mode): return app.selected_mode - prepared = await app.navigation_reader.read(CommsNavigationRequest( - comms, owner_mode, me, conversation, source.coordination_root)) + prepared = await app.navigation_reader.read( + CommsNavigationRequest( + comms, owner_mode, me, conversation, source.coordination_root + ) + ) except (UnknownChannelError, UnregisteredThreadError, RouteChanged) as error: app.show_error(error, "Comms target unavailable") return app.selected_mode @@ -213,8 +294,14 @@ async def history(self, *, owner_mode: str, project_path: Path, me: str, # factories carry its identity, so no key-to-mode mirror can go stale. entry = self.find(prepared.key.conversation.view_identity(prepared.key)) if entry is None: - entry = HistorySessionEntry(f"comms-{next(self.identities)}", prepared.key, - project_path, prepared.owner_root, prepared.participants, source) + entry = HistorySessionEntry( + f"comms-{next(self.identities)}", + prepared.key, + project_path, + prepared.owner_root, + prepared.participants, + source, + ) await self.open(entry) else: await app.select_session(entry.mode) @@ -225,7 +312,9 @@ async def preview(self, path: Path, *, position=None) -> str: path = path.expanduser().resolve() entry = self.find(path) if entry is None: - entry = PreviewSessionEntry.of(f"preview-{next(self.identities)}", path, self.app.selected_mode) + entry = PreviewSessionEntry.of( + f"preview-{next(self.identities)}", path, self.app.selected_mode + ) await self.open(entry, after=self.app.selected_mode) else: await self.app.select_session(entry.mode) @@ -259,12 +348,20 @@ async def close(self, mode: str) -> None: await self.close_many((mode,)) async def close_many(self, modes: tuple[str, ...]) -> None: - entries = tuple(entry for mode in modes if (entry := self.get(mode)) is not None) + entries = tuple( + entry for mode in modes if (entry := self.get(mode)) is not None + ) if not entries: return - closing = tuple(member for member in self.members - if any(member.depends_on(entry.mode) for entry in entries)) - returning = next((member for member in closing if member.mode == self.app.selected_mode), None) + closing = tuple( + member + for member in self.members + if any(member.depends_on(entry.mode) for entry in entries) + ) + returning = next( + (member for member in closing if member.mode == self.app.selected_mode), + None, + ) for member in closing: member.forget(self) self.app.tab_order.close({member.mode for member in closing}) @@ -291,9 +388,13 @@ def observed(self, event: core_events.CoordinationObserved) -> None: # registration or channel changed. observed = self.app.coordination_access.observation.observed() if not any( - not observed.observes(owner) - for member in self.members - for owner, _ in (*member.original_threads(self), *member.original_channels())): + not observed.observes(owner) + for member in self.members + for owner, _ in ( + *member.original_threads(self), + *member.original_channels(), + ) + ): return revision = event.revision if not revision.changed_since(RegistrationsView(), self._retired_at): @@ -316,8 +417,10 @@ async def retire_missing(self) -> None: # Borrow each open entry, including hidden views. Core decides # which of their sources no longer exist; Toad only closes the views. - originals = tuple((member, member.original_threads(self), member.original_channels()) - for member in self.members) + originals = tuple( + (member, member.original_threads(self), member.original_channels()) + for member in self.members + ) asked: dict[str, tuple[set, set]] = {} # The observation service retires views on its own root. observed = self.app.coordination_access.observation.observed() @@ -348,9 +451,24 @@ def read(): answers = await self.app.preparation.run_thread(read) # A service replaced while the read joined revokes its answer. observed = self.app.coordination_access.observation.observed() - retired = {root: answer for root, (service, answer) in answers.items() if observed.uses(service)} - removed = tuple(member.mode for member, threads, channels in originals - if self.get(member.mode) is member and ( - any(root in retired and identity in retired[root].threads for root, identity in threads) - or any(root in retired and target in retired[root].channels for root, target in channels))) + retired = { + root: answer + for root, (service, answer) in answers.items() + if observed.uses(service) + } + removed = tuple( + member.mode + for member, threads, channels in originals + if self.get(member.mode) is member + and ( + any( + root in retired and identity in retired[root].threads + for root, identity in threads + ) + or any( + root in retired and target in retired[root].channels + for root, target in channels + ) + ) + ) await self.close_many(removed) diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index e1d09a534..8ac8fd177 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -50,9 +50,12 @@ class SessionViewState: def capture(cls, conversation: Conversation) -> "SessionViewState": editor = conversation.prompt.prompt_text_area return cls( - editor.capture_editor_state(), conversation.visible_categories, + editor.capture_editor_state(), + conversation.visible_categories, conversation.window.park_reader(conversation.contents), - editor.shell_mode, conversation.input_histories, conversation.take_initial_prompt(), + editor.shell_mode, + conversation.input_histories, + conversation.take_initial_prompt(), ) def prepare_transcript(self, conversation: Conversation) -> None: @@ -93,13 +96,14 @@ def __init__(self) -> None: self.state: SessionViewState | None = None @abstractmethod - async def release_binding(self, conversation: Conversation, screen: "MainScreen") -> None: ... + async def release_binding( + self, conversation: Conversation, screen: "MainScreen" + ) -> None: ... @abstractmethod async def attach_binding(self, conversation: Conversation) -> None: ... - class OperationalSessionSources: """Own existing operational resources while the view is detached.""" @@ -110,7 +114,9 @@ def __init__(self) -> None: def status_row(self, conversation: Conversation): row = self.activity_row(conversation).with_delivery(conversation.input_delivery) - return conversation.agent_binding.session_disposition.present(row, row.disconnected) + return conversation.agent_binding.session_disposition.present( + row, row.disconnected + ) def activity_row(self, conversation: Conversation): # A Core history source owns status even when its ACP load fails. @@ -145,22 +151,33 @@ def input_ready(self, conversation: Conversation) -> bool: return False if conversation.transcript.readable: presentation = conversation.thread_presentation - return (presentation is not None - and ExistingOwnerPlan.from_binding(presentation.binding) is not None) + return ( + presentation is not None + and ExistingOwnerPlan.from_binding(presentation.binding) is not None + ) return True def unavailable_reason(self, conversation: Conversation) -> str: presentation = conversation.thread_presentation agent = conversation.agent_binding - if (conversation.transcript.readable and presentation is not None - and ExistingOwnerPlan.from_binding(presentation.binding) is None): + if ( + conversation.transcript.readable + and presentation is not None + and ExistingOwnerPlan.from_binding(presentation.binding) is None + ): line = self.activity_row(conversation).presentation.summary elif agent.configuration.model.requires_selection: - line = "Choose a model or connect a provider (click here). Your draft is kept." + line = ( + "Choose a model or connect a provider (click here). Your draft is kept." + ) else: line = self.activity_row(conversation).presentation.summary return self.activity_row(conversation).status.with_disconnection( - line, lambda: agent.session_disposition.present(line, lambda status: status.notice)) + line, + lambda: agent.session_disposition.present( + line, lambda status: status.notice + ), + ) def start_agent(self, conversation: Conversation) -> None: conversation.agent_binding.start_agent(self, conversation) @@ -171,15 +188,23 @@ def create_agent(self, conversation: Conversation) -> None: from toad.core import events as core_events assert conversation._agent_data is not None - agent = Agent(conversation.project_path, conversation._agent_data, - conversation._agent_session_id, conversation._session_pk, - app_events=conversation.app.events, - transcripts=conversation.app.coordination_access.transcripts) + agent = Agent( + conversation.project_path, + conversation._agent_data, + conversation._agent_session_id, + conversation._session_pk, + app_events=conversation.app.events, + transcripts=conversation.app.coordination_access.transcripts, + ) agent.session.load_plan = self.load_plan conversation._native_agent_started_here = True conversation.bind_agent(agent) - agent.controller.start_operation(agent.start(conversation.app.session_navigation.processes)) - conversation.publish_core(core_events.SessionSubtitleChanged(conversation.agent_title)) + agent.controller.start_operation( + agent.start(conversation.app.session_navigation.processes) + ) + conversation.publish_core( + core_events.SessionSubtitleChanged(conversation.agent_title) + ) def attach_started(self, screen: "MainScreen", plan) -> None: """An explicit Start action may attach the same retained view to its new owner.""" @@ -238,12 +263,17 @@ async def submit(self, owner, event): view = owner.view async def presented(sent): - view.run_worker(view.input_histories.prompt.record(event.body), group="history") + view.run_worker( + view.input_histories.prompt.record(event.body), group="history" + ) view.window.jump_to_latest() self.external_transcript_changed(view) await view.transcript.binding.thread_scope.submit_mailbox( - view, event, DmConversation, presented, + view, + event, + DmConversation, + presented, ) @@ -260,7 +290,11 @@ def __init__(self) -> None: @property def operational_binding(self): - return self.widget.agent_binding if self.widget is not None else self.sources.agent_binding + return ( + self.widget.agent_binding + if self.widget is not None + else self.sources.agent_binding + ) @property def retained_widget_count(self) -> int: @@ -276,8 +310,11 @@ def retained_paint_bytes(self) -> int: # Drawn history rows: about one cell of text and style per column. if self.widget is None: return 0 - return sum(page.line_count * page.line_width * 8 - for history in self.widget.window.histories for page in history.pages) + return sum( + page.line_count * page.line_width * 8 + for history in self.widget.window.histories + for page in history.pages + ) def compose_content(self, screen: "MainScreen") -> Widget: return SessionSurfaceSlot() @@ -288,13 +325,17 @@ async def prepare(self, screen: "MainScreen") -> None: async def retire(self, screen: "MainScreen") -> None: await screen.app.workspace_chrome.native.retire(screen) - async def release_binding(self, conversation: Conversation, screen: "MainScreen") -> None: + async def release_binding( + self, conversation: Conversation, screen: "MainScreen" + ) -> None: await self.sources.detach(conversation, screen) async def attach_binding(self, conversation: Conversation) -> None: await self.sources.present(conversation) - async def apply_coordination(self, screen: "MainScreen", event: CoordinationChangedUpdate) -> None: + async def apply_coordination( + self, screen: "MainScreen", event: CoordinationChangedUpdate + ) -> None: """Apply a coordination update's view work to this session's conversation. The workspace accepts one session's native tree at a time, so the @@ -304,7 +345,10 @@ async def apply_coordination(self, screen: "MainScreen", event: CoordinationChan conversation = self.widget if conversation is None: return - if event.worktree is not None and (project := Path(event.worktree)) != conversation.project_path: + if ( + event.worktree is not None + and (project := Path(event.worktree)) != conversation.project_path + ): await conversation.sync_project_path(project) conversation.input_histories.bind_scope(f"thread:{event.thread.name}") @@ -385,8 +429,11 @@ def widget(self) -> Conversation | None: def _presentations(self): app = self._app() - return tuple(presentation for view in app.workspace_sessions.views.values() - for presentation in view.retained_native_presentations()) + return tuple( + presentation + for view in app.workspace_sessions.views.values() + for presentation in view.retained_native_presentations() + ) async def retire(self, screen: "MainScreen") -> None: async with self._lock: @@ -403,7 +450,9 @@ async def activate(self, screen: "MainScreen") -> None: async with self._lock: if self.view is screen: return - assert self.view is None, "Departing source must retire before accepting the next source" + assert ( + self.view is None + ), "Departing source must retire before accepting the next source" async with screen.presentation.acquire(screen) as conversation: self.view = screen await self._trim_retained(conversation) @@ -427,14 +476,29 @@ async def _trim_retained(self, selected: Conversation) -> None: candidates = {} for screen, owner in self._presentations(): candidates.setdefault(screen.id, []).append(owner) - required = (self.view.presentation, *( - owner for owner in candidates.get(self.view.id, ()) - if owner is not self.view.presentation)) - ordered = dict.fromkeys((*required, *( - owner for identity in app.tab_order.recent - for owner in candidates.get(identity, ())))) + required = ( + self.view.presentation, + *( + owner + for owner in candidates.get(self.view.id, ()) + if owner is not self.view.presentation + ), + ) + ordered = dict.fromkeys( + ( + *required, + *( + owner + for identity in app.tab_order.recent + for owner in candidates.get(identity, ()) + ), + ) + ) kept = budget.fit( - ordered, required, app.size.height, app.preparation.max_bytes, + ordered, + required, + app.size.height, + app.preparation.max_bytes, ) for owner in ordered: if owner not in kept: diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 9eaf7d635..94f2ba7c9 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -9,17 +9,17 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar, Never +from agent_comms.channel_targets import ThreadTarget from agent_comms.errors import RelationViolationError from agent_comms.route_selection import RouteSelection -from agent_comms.channel_targets import ThreadTarget from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.observation import ReadTranscript, ReadTranscriptUpdate from textual.dom import DOMNode -from toad.navigation_context import NavigationOwner -from toad.project_path_owner import ProjectPathOwner from toad.core.events import ThreadRowsChanged from toad.core_event_carrier import ThreadCallbackSubscription +from toad.navigation_context import NavigationOwner +from toad.project_path_owner import ProjectPathOwner from toad.resource_release import ResourceRelease from toad.status_thread import NoStatusThread, StatusThread @@ -41,7 +41,9 @@ class TranscriptScope(ABC): def readable(self) -> bool: ... @abstractmethod - def original_threads(self, identity: SessionIdentity) -> tuple[tuple[str, ThreadIncarnation], ...]: ... + def original_threads( + self, identity: SessionIdentity + ) -> tuple[tuple[str, ThreadIncarnation], ...]: ... @abstractmethod def status_thread(self, access) -> StatusThread: ... @@ -59,7 +61,9 @@ def update_request(self, previous, **bounds) -> ReadTranscriptUpdate: ... def mailbox_target(self) -> ThreadTarget: ... @abstractmethod - async def submit_mailbox(self, view, event, conversation_type, presented) -> None: ... + async def submit_mailbox( + self, view, event, conversation_type, presented + ) -> None: ... @dataclass(frozen=True) @@ -68,7 +72,9 @@ class KnownTranscript(TranscriptScope): name: str readable = True - def original_threads(self, identity: SessionIdentity) -> tuple[tuple[str, ThreadIncarnation], ...]: + def original_threads( + self, identity: SessionIdentity + ) -> tuple[tuple[str, ThreadIncarnation], ...]: return ((self.root, identity.thread_incarnation()),) def status_thread(self, access) -> StatusThread: @@ -76,9 +82,17 @@ def status_thread(self, access) -> StatusThread: def observe(self, view, access, resume) -> ResourceRelease: release = ResourceRelease() - release.callback(view.retire_core, view.observe_core_callback( - access.events, ThreadRowsChanged, resume, - factory=partial(ThreadCallbackSubscription, scope=(self.root, self.name)))) + release.callback( + view.retire_core, + view.observe_core_callback( + access.events, + ThreadRowsChanged, + resume, + factory=partial( + ThreadCallbackSubscription, scope=(self.root, self.name) + ), + ), + ) return release def snapshot_request(self, **bounds) -> ReadTranscript: @@ -92,7 +106,9 @@ def mailbox_target(self) -> ThreadTarget: async def submit_mailbox(self, view, event, conversation_type, presented) -> None: await view.submit_comms_message( - self.root, conversation_type(self.mailbox_target()), event.body, + self.root, + conversation_type(self.mailbox_target()), + event.body, lambda: view.is_attached and view.transcript.binding.thread_scope == self, presented, ) @@ -102,7 +118,9 @@ async def submit_mailbox(self, view, event, conversation_type, presented) -> Non class NoTranscript(TranscriptScope): readable = False - def original_threads(self, identity: SessionIdentity) -> tuple[tuple[str, ThreadIncarnation], ...]: + def original_threads( + self, identity: SessionIdentity + ) -> tuple[tuple[str, ThreadIncarnation], ...]: return () def status_thread(self, access) -> StatusThread: @@ -231,7 +249,9 @@ def resolve_thread(self) -> str: service = self.app.coordination_access.require(selected).service return identity.resolve_thread(service.registry, self.project_root) - def agent_for_opening(self, default: AgentDefinition | None) -> AgentDefinition | None: + def agent_for_opening( + self, default: AgentDefinition | None + ) -> AgentDefinition | None: return default @@ -241,7 +261,9 @@ class ViewSessionOrigin(ProjectSessionOrigin): _agent: AgentDefinition | None _agent_session_id: str | None - def agent_for_opening(self, default: AgentDefinition | None) -> AgentDefinition | None: + def agent_for_opening( + self, default: AgentDefinition | None + ) -> AgentDefinition | None: return self._agent or default def attach_agent_definition(self) -> AgentDefinition | None: @@ -258,5 +280,10 @@ def matches_launch(self, agent_identity: str, session_id: str) -> bool: if session_id == self._agent_session_id or live.matches_session(session_id): return True root = self.coordination_root - return root is not None and self.app.coordination_access.require( - RouteSelection.capture(root)).service.registry.canonical_name(session_id) == self.resolve_thread() + return ( + root is not None + and self.app.coordination_access.require( + RouteSelection.capture(root) + ).service.registry.canonical_name(session_id) + == self.resolve_thread() + ) diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py index 5d30ef481..2c517319d 100644 --- a/src/toad/status_thread.py +++ b/src/toad/status_thread.py @@ -16,7 +16,10 @@ from toad.core_event_carrier import ThreadCallbackSubscription if TYPE_CHECKING: - from toad.widgets.observed_thread_activity import ObservedThreadActivity, ThreadStatusOwner + from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, + ) class StatusThread(ABC): @@ -62,8 +65,13 @@ def bind(self, activity: ObservedThreadActivity) -> None: def observe(self, activity: ObservedThreadActivity) -> None: access = activity.app.coordination_access activity.observe_core_callback( - access.events, ThreadRowsChanged, activity.status_changed, - factory=partial(ThreadCallbackSubscription, scope=(str(self.root), self.name))) + access.events, + ThreadRowsChanged, + activity.status_changed, + factory=partial( + ThreadCallbackSubscription, scope=(str(self.root), self.name) + ), + ) @dataclass(frozen=True) @@ -76,18 +84,27 @@ def bind(self, activity: ObservedThreadActivity) -> None: def refresh(self, view: ThreadStatusOwner) -> None: super().refresh(view) - view.run_worker(self.read_foreign_thread(view), group="foreign-thread-status", exclusive=True) + view.run_worker( + self.read_foreign_thread(view), + group="foreign-thread-status", + exclusive=True, + ) async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: try: - presentation = await view.app.coordination_access.transcripts.thread_presentation( - str(self.root), self.name) + presentation = ( + await view.app.coordination_access.transcripts.thread_presentation( + str(self.root), self.name + ) + ) row = ThreadStatusModel.known(self.name, presentation) except CoordinationReadUnavailable: return except (OSError, ValueError, RuntimeError) as error: presentation = None - row = ThreadStatusModel.unreadable(self.name, UnreadableHistory.capture(error)) + row = ThreadStatusModel.unreadable( + self.name, UnreadableHistory.capture(error) + ) if view.status_thread() != self: return view.thread_activity.show(row) diff --git a/src/toad/thread_identity.py b/src/toad/thread_identity.py index ae077d7be..d2d4d5200 100644 --- a/src/toad/thread_identity.py +++ b/src/toad/thread_identity.py @@ -1,4 +1,5 @@ """A session's absent, local or wire-bound thread, with its owned behavior.""" + from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass @@ -50,6 +51,7 @@ def display_root(self) -> str: def inspection_state(self): from toad.core.context_inspection import DetachedInspection + return DetachedInspection() def root_path(self) -> Path | None: @@ -69,7 +71,9 @@ def bind_history(self, histories) -> None: def resolve_thread(self, registry, project: Path) -> str: """An unbound view asks Core to discover its project's session.""" - return registry.session_thread(project, self.thread_name()).name_or(self.thread_name()) + return registry.session_thread(project, self.thread_name()).name_or( + self.thread_name() + ) def command_target[T](self, target: Callable[[str], T]) -> T: """A named session addresses backend commands as its thread.""" @@ -98,6 +102,7 @@ def display_thread(self) -> str: @dataclass(frozen=True) class LocalThreadIdentity(NamedThreadIdentity): """A named session without a managed wire.""" + thread: str def thread_name(self) -> str: @@ -149,6 +154,7 @@ def display_root(self) -> str: def inspection_state(self): from toad.core.context_inspection import ReadingOwnerInspection + return ReadingOwnerInspection() def root_path(self) -> Path: diff --git a/src/toad/thread_navigation.py b/src/toad/thread_navigation.py index b904cbd3e..7902292fb 100644 --- a/src/toad/thread_navigation.py +++ b/src/toad/thread_navigation.py @@ -1,4 +1,5 @@ """Thread opening tracks requests, avoids duplicates and opens native views.""" + from __future__ import annotations import asyncio @@ -7,7 +8,11 @@ from typing import TYPE_CHECKING from agent_comms.errors import RelationViolationError -from toad.navigation_preparation import OpenThread, ThreadNavigationRequest, ThreadOpening +from toad.navigation_preparation import ( + OpenThread, + ThreadNavigationRequest, + ThreadOpening, +) if TYPE_CHECKING: from toad.app import ToadApp @@ -25,7 +30,12 @@ class ThreadOrigin: @classmethod def capture(cls, app: ToadApp, source: ProjectSessionOrigin) -> ThreadOrigin: - return cls(source, app.selected_mode, source.coordination_root, source.thread_identity.thread_name()) + return cls( + source, + app.selected_mode, + source.coordination_root, + source.thread_identity.thread_name(), + ) def current(self, navigator: ThreadNavigator, owner_mode: str) -> bool: app = navigator.app @@ -48,7 +58,9 @@ async def open(self, *, owner_mode: str, project_path: Path, target: str) -> str return app.selected_mode origin = ThreadOrigin.capture(app, source) access = app.coordination_access - comms = await app.preparation.run_thread(lambda: access.observation.observed().service) + comms = await app.preparation.run_thread( + lambda: access.observation.observed().service + ) requested_root = str(comms.root) if not origin.current(self, owner_mode): return app.selected_mode @@ -64,7 +76,9 @@ async def open(self, *, owner_mode: str, project_path: Path, target: str) -> str return details.mode_name if root is not None: open_threads.append(OpenThread(details.mode_name, root, name)) - request = ThreadNavigationRequest(comms, target, project_path, tuple(open_threads)) + request = ThreadNavigationRequest( + comms, target, project_path, tuple(open_threads) + ) key = owner_mode, request.root, request.target if pending := self.pending.get(key): return await asyncio.shield(pending.task) @@ -83,15 +97,23 @@ async def close(self) -> None: await asyncio.gather(*tasks, return_exceptions=True) async def mount_loading(self, opening: ThreadOpening) -> None: - await self.app.session_navigation.new(lambda: opening.view, title=opening.request.target) + await self.app.session_navigation.new( + lambda: opening.view, title=opening.request.target + ) async def mount(self, prepared: ThreadNavigation, opening: ThreadOpening) -> str: view = opening.view prepared.bind(view) self.app.session_navigation.bind_opened( - view, original=(prepared.root, prepared.thread.incarnation), - name=prepared.thread.name, project=prepared.project, + view, + original=(prepared.root, prepared.thread.incarnation), + name=prepared.thread.name, + project=prepared.project, ) await view._prepare_content() await view.wait_content_ready() - return view.id if self.app.workspace_sessions.views.get(view.id) is view else self.app.selected_mode + return ( + view.id + if self.app.workspace_sessions.views.get(view.id) is view + else self.app.selected_mode + ) diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 06c910744..3d3461b3a 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -11,7 +11,11 @@ from weakref import ReferenceType, ref from toad.status_thread import NoStatusThread -from agent_comms.transcripts import TranscriptCursor, TranscriptPage, UnreadTranscriptCursor +from agent_comms.transcripts import ( + TranscriptCursor, + TranscriptPage, + UnreadTranscriptCursor, +) from agent_comms.transcript_events import TranscriptEvent from agent_comms.sent_message import OutgoingMessageReference @@ -36,7 +40,6 @@ UniqueRevision, ) - if TYPE_CHECKING: from toad.widgets.conversation import Contents from toad.widgets.transcript_history import LineHistory @@ -59,13 +62,18 @@ def __deepcopy__(self, memo): @classmethod def prepare(cls, page: TranscriptPage) -> PreparedTranscriptPage: """Prepare and measure one whole page in the worker that owns its result.""" - fragments = transcript_fragments(page.events, resolve_sent=page.after.wire.resolve_sent_messages) + fragments = transcript_fragments( + page.events, resolve_sent=page.after.wire.resolve_sent_messages + ) return cls.measured(page, fragments) @classmethod def measured(cls, page, fragments, **view): - coverage = frozenset(reference for fragment in fragments - for reference in fragment.outgoing_sources) + coverage = frozenset( + reference + for fragment in fragments + for reference in fragment.outgoing_sources + ) prepared = cls(page, fragments, 0, outgoing_coverage=coverage, **view) prepared.measure_sources() return prepared @@ -76,9 +84,12 @@ def source_payload_bytes(self) -> int: def measure_sources(self): """The page owns its events; each fragment owns only its acquired rows.""" - self.retained_bytes = (256 + 64 * len(self.outgoing_coverage) - + self.source_payload_bytes - + sum(fragment.retained_bytes for fragment in self.fragments)) + self.retained_bytes = ( + 256 + + 64 * len(self.outgoing_coverage) + + self.source_payload_bytes + + sum(fragment.retained_bytes for fragment in self.fragments) + ) @classmethod def empty(cls, page): @@ -86,21 +97,34 @@ def empty(cls, page): def project(self, fragments): if len(fragments) == len(self.fragments) and all( - selected is original for selected, original in zip(fragments, self.fragments)): + selected is original + for selected, original in zip(fragments, self.fragments) + ): return self.for_view() return type(self).measured(self.page, fragments) def configure(self, *, batch_size): if self.fragment_range is None: - self.set_fragment_range(slice(max(0, len(self.fragments) - batch_size), len(self.fragments))) + self.set_fragment_range( + slice(max(0, len(self.fragments) - batch_size), len(self.fragments)) + ) @property def fragments_in_range(self): - return self.fragments[self.start:self.stop] - - async def prepare_rows(self, view: LineHistory, current: Callable[[], bool], *, - target: LineHistory | Contents, selected: frozenset[type[MessageCategory]], - boundary: int, end: PageEnd, rows_needed: int, limit: int) -> int: + return self.fragments[self.start : self.stop] + + async def prepare_rows( + self, + view: LineHistory, + current: Callable[[], bool], + *, + target: LineHistory | Contents, + selected: frozenset[type[MessageCategory]], + boundary: int, + end: PageEnd, + rows_needed: int, + limit: int, + ) -> int: """Select one directional range from its actual rendered rows. Latest destinations and ordinary edge travel use the same selection. @@ -114,14 +138,21 @@ async def prepare_rows(self, view: LineHistory, current: Callable[[], bool], *, batch = tuple(islice(indices, min(view.budget.batch_items, limit - take))) if not batch: break - await view.prepare_fragments(tuple(self.fragments[index] for index in batch), - current, target=target, selected=selected) + await view.prepare_fragments( + tuple(self.fragments[index] for index in batch), + current, + target=target, + selected=selected, + ) if not current(): return 0 for index in batch: fragment = self.fragments[index] - lines = (fragment.lines_for(target.line_width, view.app.colours.line_theme) - if fragment.shown_in(selected) else None) + lines = ( + fragment.lines_for(target.line_width, view.app.colours.line_theme) + if fragment.shown_in(selected) + else None + ) take += 1 start, stop = min(start, index), max(stop, index + 1) rows += 0 if lines is None else lines.height @@ -142,7 +173,9 @@ def set_fragment_range(self, selected: slice) -> None: self.fragment_range = selected.start, selected.stop def capture_range(self) -> PageFragmentRange: - return PageFragmentRange(ref(self), self.start, self.stop, self.fragments[self.start:self.stop]) + return PageFragmentRange( + ref(self), self.start, self.stop, self.fragments[self.start : self.stop] + ) def restore_range(self, fragment_range: PageFragmentRange) -> None: if fragment_range.page() is not self: @@ -158,7 +191,9 @@ async def extend(self, view, prepared: PreparedTranscriptPage, current) -> bool: if not current(): return False if prepared.fragments is not self.fragments: - raise ValueError("Transcript input replacement requires a new prepared page") + raise ValueError( + "Transcript input replacement requires a new prepared page" + ) selected = slice(min(self.start, prepared.start), max(self.stop, prepared.stop)) return await self.replace_range(view, selected, current) @@ -205,8 +240,12 @@ class PageFragmentRange: def __eq__(self, other): if not isinstance(other, PageFragmentRange): return NotImplemented - return ((page := self.page()) is not None and page is other.page() - and self.start == other.start and self.stop == other.stop) + return ( + (page := self.page()) is not None + and page is other.page() + and self.start == other.start + and self.stop == other.stop + ) @dataclass(frozen=True) @@ -249,7 +288,9 @@ def prepare(self) -> FilteredTranscriptBatch: fragment = self.fragments[stop] if fragment.shown_in(self.selected): matches.append(fragment) - return FilteredTranscriptBatch(self.page.project(tuple(reversed(matches))), stop) + return FilteredTranscriptBatch( + self.page.project(tuple(reversed(matches))), stop + ) class HeldProjection(ABC): @@ -294,17 +335,26 @@ def __init__(self) -> None: @property def retained_source_bytes(self) -> int: - return sum(page.retained_bytes for page in self.held_pages) + self.projection_source.retained_source_bytes - - def measure_held(self, pages: tuple[PreparedTranscriptPage, ...], - projection: HeldProjection = NoProjection()) -> None: + return ( + sum(page.retained_bytes for page in self.held_pages) + + self.projection_source.retained_source_bytes + ) + + def measure_held( + self, + pages: tuple[PreparedTranscriptPage, ...], + projection: HeldProjection = NoProjection(), + ) -> None: """Reader ranges reference these same fragments; charge them once.""" for page in pages: page.measure_sources() projection.measure_projection() - def park(self, pages: tuple[PreparedTranscriptPage, ...], - projection: HeldProjection = NoProjection()) -> None: + def park( + self, + pages: tuple[PreparedTranscriptPage, ...], + projection: HeldProjection = NoProjection(), + ) -> None: if self.closed: raise ValueError("Cannot retain a revoked transcript source") self.held_pages, self.projection_source = pages, projection @@ -389,7 +439,9 @@ def close(self) -> None: class TranscriptPageProjection(ABC): @abstractmethod - async def project(self, page: PreparedTranscriptPage, runtime: PreparationRuntime) -> PreparedTranscriptPage: + async def project( + self, page: PreparedTranscriptPage, runtime: PreparationRuntime + ) -> PreparedTranscriptPage: pass @@ -400,13 +452,21 @@ class CategoryProjection(TranscriptPageProjection): def fragments_for(self, page): return page.fragments - async def project(self, page: PreparedTranscriptPage, runtime: PreparationRuntime) -> PreparedTranscriptPage: + async def project( + self, page: PreparedTranscriptPage, runtime: PreparationRuntime + ) -> PreparedTranscriptPage: # Filter one source page on the model lane. The same bounded pager # used for unfiltered history decides which widgets to show. fragments = self.fragments_for(page) - matches = await runtime.submit(TranscriptFilterWork( - page, fragments, self.selected, len(fragments), len(fragments), - )) + matches = await runtime.submit( + TranscriptFilterWork( + page, + fragments, + self.selected, + len(fragments), + len(fragments), + ) + ) return matches.prepared @@ -439,11 +499,14 @@ def __init__(self, request: PageRequest, through, page: TranscriptPage) -> None: f"Transcript history made no cursor progress: {type(request.end).__name__} " f"from {request.cursor!r} through {through!r} returned before={page.before!r} " f"after={page.after!r} has_older={page.has_older} has_newer={page.has_newer} " - f"events={len(page.events)}") + f"events={len(page.events)}" + ) @dataclass(frozen=True) -class TranscriptPageWork[Cursor = TranscriptCursor | None](ReusableWork[PreparedTranscriptPage], ScopedWork[PreparedTranscriptPage]): +class TranscriptPageWork[Cursor = TranscriptCursor | None]( + ReusableWork[PreparedTranscriptPage], ScopedWork[PreparedTranscriptPage] +): scope: PreparationScope loader: Callable[..., Awaitable[TranscriptPage]] through: Cursor @@ -453,13 +516,19 @@ class TranscriptPageWork[Cursor = TranscriptCursor | None](ReusableWork[Prepared @property def work_key(self) -> WorkKey: # An older page is immutable as its source grows; a newer read ends at the source end. - return WorkKey(type(self), PageRevision(self.request.end.work_cursor(self.through), self.request), self.scope) + return WorkKey( + type(self), + PageRevision(self.request.end.work_cursor(self.through), self.request), + self.scope, + ) def result_size(self, result: PreparedTranscriptPage) -> int: return result.retained_bytes async def execute( - self: TranscriptPageWork[TranscriptCursor | None], runtime: PreparationRuntime, key: WorkKey, + self: TranscriptPageWork[TranscriptCursor | None], + runtime: PreparationRuntime, + key: WorkKey, ) -> PreparedResult[PreparedTranscriptPage]: request = self.request page = await request.end.load_page(self.loader, request.cursor, self.through) @@ -495,7 +564,9 @@ class SavedHistory(PreparedPageSource): work: ClassVar[type[TranscriptPageWork]] = TranscriptPageWork def __init__( - self, loader: Callable[..., Awaitable[TranscriptPage]], through: TranscriptCursor, + self, + loader: Callable[..., Awaitable[TranscriptPage]], + through: TranscriptCursor, runtime: PreparationRuntime, ) -> None: super().__init__() @@ -523,7 +594,9 @@ def __init__(self, earlier, loader, through, runtime): def read_task(self, request): # Keep the upstream task's declared native/wire behavior, bounded here. - return replace(self.earlier.read_task(request), scope=self.scope, through=self.through) + return replace( + self.earlier.read_task(request), scope=self.scope, through=self.through + ) async def _get(self, request): return await request.end.edge((self.earlier.get, super()._get))(request) @@ -534,15 +607,20 @@ async def _prepare(self, request): class SourceObservation: async def apply(self, history, source) -> bool: - await history.jump_latest(LatestViewportRequest(LatestPosition.hold(history.window))) + await history.jump_latest( + LatestViewportRequest(LatestPosition.hold(history.window)) + ) return not history.undrawn class Unchanged(SourceObservation): async def apply(self, history, source) -> bool: if not history.undrawn: - return (history.pages[-1].prepared.page.after != source.through - and await history.extend(source.through)) + return history.pages[ + -1 + ].prepared.page.after != source.through and await history.extend( + source.through + ) return await super().apply(history, source) @@ -638,7 +716,9 @@ def measure_projection(self) -> None: self.measure_held(self.held_pages, self.projection_source) def __init__( - self, boundary: PreparedTranscriptPage, source: PreparedPageSource, + self, + boundary: PreparedTranscriptPage, + source: PreparedPageSource, projection: TranscriptPageProjection, ) -> None: super().__init__() @@ -652,10 +732,14 @@ def __init__( async def boundary(self) -> PreparedTranscriptPage: if self._projected_boundary is None: projected = await self.projection.project(self._boundary, self.runtime) - self._projected_boundary = replace(projected, page=replace( - projected.page, has_newer=False, - has_older=self._raw.has_older(projected.page), - )) + self._projected_boundary = replace( + projected, + page=replace( + projected.page, + has_newer=False, + has_older=self._raw.has_older(projected.page), + ), + ) return self._projected_boundary def prefix(self, through) -> PreparedPageSource: diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index fb8ef6ed3..8fa882ff9 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -1,4 +1,5 @@ """Source-fenced saved transcript publication; commit policy stays declared.""" + from __future__ import annotations import asyncio @@ -14,20 +15,41 @@ from agent_comms.transcript_events import TranscriptCoverage from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate from agent_comms.mro_dispatch import MroDispatch, handles -from agent_comms.ui_model.observation import NotPublished, Published, TranscriptPublicationState -from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision, TranscriptChanged +from agent_comms.ui_model.observation import ( + NotPublished, + Published, + TranscriptPublicationState, +) +from agent_comms.coordination_errors import ( + CoordinationReadUnavailable, + StaleRevision, + TranscriptChanged, +) from textual.worker import Worker, WorkerCancelled from textual.await_complete import AwaitComplete -from toad.widgets.history_anchor import HeldReaderDestination, TailPosition, HistoryWindow +from toad.widgets.history_anchor import ( + HeldReaderDestination, + TailPosition, + HistoryWindow, +) from toad.widgets.committed_presentation import ( - CommitBarrier, CommitParticipant, CommitEvidence, commit_plan, - protected_blocks, retirement_candidates, + CommitBarrier, + CommitParticipant, + CommitEvidence, + commit_plan, + protected_blocks, + retirement_candidates, ) from toad.widgets.transcript_history import TranscriptHistory from toad.widgets.session_details import SessionDetails from toad.transcript_preparation import PreparedPageSource from toad.core.events import CoreEventStream -from toad.session_scope import NoTranscript, SessionOrigin, SessionScope, TranscriptScope +from toad.session_scope import ( + NoTranscript, + SessionOrigin, + SessionScope, + TranscriptScope, +) if TYPE_CHECKING: from toad.widgets.conversation import Conversation @@ -38,14 +60,17 @@ class BoundFrame: binding: BoundView owner: TranscriptPresentation generation: int + @classmethod def capture(cls, binding, owner, *args, **extra): return cls(binding, owner, owner.generation, *args, **extra) def current(self): - return (self.owner.binding is self.binding - and self.owner.generation == self.generation - and self.binding.current()) + return ( + self.owner.binding is self.binding + and self.owner.generation == self.generation + and self.binding.current() + ) def painted(self, cursor): if self.current(): @@ -67,17 +92,22 @@ def record(self, cursor): class TranscriptPublication(BoundFrame, ABC): """Holds the source, resources and generation for one read and publication.""" - def __init__(self, binding: BoundView, owner: TranscriptPresentation, generation: int) -> None: + def __init__( + self, binding: BoundView, owner: TranscriptPresentation, generation: int + ) -> None: super().__init__(binding, owner, generation) self.thread_scope = binding.thread_scope self.destination = HeldReaderDestination.held(binding.window) - self.captured = binding.view.turns.owner.captured_snapshot(tuple(binding.contents.children)) + self.captured = binding.view.turns.owner.captured_snapshot( + tuple(binding.contents.children) + ) def native_current(self) -> bool: """Anonymous native output must have settled before the source captured it.""" return self.current() and all( widget.commit_claim.accepts_native(widget, self.captured) - for widget in self.binding.contents.children if isinstance(widget, CommitParticipant) + for widget in self.binding.contents.children + if isinstance(widget, CommitParticipant) ) def source_bound(self, cursor: TranscriptCursor) -> TranscriptCursor: @@ -85,14 +115,19 @@ def source_bound(self, cursor: TranscriptCursor) -> TranscriptCursor: if self.native_current(): return cursor history = self.binding.history - return cursor.over_native((history.page_source.through,) - if history in self.captured and history.state.reports_coverage else ()) + return cursor.over_native( + (history.page_source.through,) + if history in self.captured and history.state.reports_coverage + else () + ) def source_current(self, cursor: TranscriptCursor) -> bool: return self.current() and self.source_bound(cursor) == cursor def current(self) -> bool: - return super().current() and self.thread_scope == self.owner.binding.thread_scope + return ( + super().current() and self.thread_scope == self.owner.binding.thread_scope + ) async def publish(self) -> bool: """Wait for the UI to apply queued messages before acquiring their resources. @@ -116,7 +151,7 @@ async def publish(self) -> bool: try: if await self.capture_application(): return await self.publish_applied() - except (CoordinationReadUnavailable, StaleRevision): + except CoordinationReadUnavailable, StaleRevision: # No usable source snapshot was acquired. Keep the original operation, # mounted history and work slot unchanged until a new observation # or the existing preparation completion resumes source work. @@ -129,7 +164,9 @@ async def capture_application(self) -> bool: """Capture the original applied cohort once its source cut is known.""" if not await self.application_current(): return False - self.captured = self.binding.view.turns.owner.captured_snapshot(tuple(self.binding.contents.children)) + self.captured = self.binding.view.turns.owner.captured_snapshot( + tuple(self.binding.contents.children) + ) return True async def application_current(self) -> bool: @@ -161,7 +198,10 @@ class CanonicalSourcePublication(MroDispatch, TranscriptPublication): async def read_snapshot(self): return await self.read_past_history( - partial(self.binding.reader.updates, self.thread_scope, self.owner.current_read)) + partial( + self.binding.reader.updates, self.thread_scope, self.owner.current_read + ) + ) async def read_past_history(self, read, **whole): """What follows the mounted history's end while its source only grew, else the whole latest page.""" @@ -176,7 +216,7 @@ async def publish_applied(self) -> bool: if not self.owner.readable: return True snapshot = await self.read_snapshot() - handler, = self.handlers_for(snapshot) + (handler,) = self.handlers_for(snapshot) return await handler(snapshot) @handles(TranscriptDraftUpdate) @@ -194,7 +234,10 @@ def show_draft(self, snapshot) -> None: if draft is None: widget.clear() else: - widget.show(draft, resolve_sent=snapshot.identity.wire_frontier.resolve_sent_messages) + widget.show( + draft, + resolve_sent=snapshot.identity.wire_frontier.resolve_sent_messages, + ) @handles(TranscriptSnapshotUpdate) async def publish_snapshot(self, snapshot: TranscriptSnapshotUpdate) -> bool: @@ -207,7 +250,9 @@ async def publish_snapshot(self, snapshot: TranscriptSnapshotUpdate) -> bool: bound = self.source_bound(page.after) if bound != page.after: self.owner.dirty = self.owner.commit_required = True - page = await self.owner.get_transcript_page(after=snapshot.identity.after, through=bound) + page = await self.owner.get_transcript_page( + after=snapshot.identity.after, through=bound + ) # Preserve this cut's original cohort across the bounded read. if not await self.application_current(): return True @@ -265,8 +310,11 @@ def defer(self, publication: TranscriptPublication) -> None: self.pending.append(publication) view = publication.binding.view self.observation.close() - self.observation.enter_context(publication.thread_scope.observe( - view, view.app.coordination_access, self.resume)) + self.observation.enter_context( + publication.thread_scope.observe( + view, view.app.coordination_access, self.resume + ) + ) def resume(self, _event=None) -> bool: """Resume kept work only at an existing source/preparation signal.""" @@ -302,7 +350,9 @@ def __init__(self, binding, owner, generation): async def read_snapshot(self): # Mounted history grows by what follows its end; without one the window needs a whole page. - return await self.read_past_history(partial(self.binding.reader.snapshot, self.thread_scope)) + return await self.read_past_history( + partial(self.binding.reader.snapshot, self.thread_scope) + ) def current(self) -> bool: return super().current() and self.plan.current(self.binding.window) @@ -313,13 +363,24 @@ def ready_to_commit(self) -> bool: view = self.binding.view if not self.owner.readable: return False - if not self.owner.commit_required and len(self.binding.contents.children) < view.MAX_LIVE_BLOCKS: + if ( + not self.owner.commit_required + and len(self.binding.contents.children) < view.MAX_LIVE_BLOCKS + ): return False - if any(isinstance(child, CommitBarrier) for child in self.binding.contents.children): + if any( + isinstance(child, CommitBarrier) for child in self.binding.contents.children + ): return False - potential = tuple(child for child in self.binding.contents.children - if child is not self.binding.history and isinstance(child, CommitParticipant)) - return self.plan.ready(self.binding.history) and self.plan.permits(view, potential) + potential = tuple( + child + for child in self.binding.contents.children + if child is not self.binding.history + and isinstance(child, CommitParticipant) + ) + return self.plan.ready(self.binding.history) and self.plan.permits( + view, potential + ) async def publish_applied(self) -> bool: if self.ready_to_commit(): @@ -341,7 +402,9 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: if prepared is None or not is_current(): return True evidence = CommitEvidence( - self.captured, prepared.sequences, prepared.history, + self.captured, + prepared.sequences, + prepared.history, TranscriptCoverage(page.events).native_inputs, prepared.outgoing, ) @@ -373,7 +436,6 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: return True - class ViewBinding: """The native view's binding and its view-dependent operations.""" @@ -447,26 +509,38 @@ class Moment(BoundFrame): @classmethod def capture(cls, binding, owner, **extra): - return super().capture(binding, owner, - source=binding.view.query_ancestor(SessionScope), **extra) + return super().capture( + binding, + owner, + source=binding.view.query_ancestor(SessionScope), + **extra, + ) def current(self): - return (super().current() and self.source.is_current - and self.source in self.binding.view.ancestors) + return ( + super().current() + and self.source.is_current + and self.source in self.binding.view.ancestors + ) @dataclass(frozen=True) class Restoration(Moment): revision: int def current(self): - return (super().current() and not self.owner.closed - and self.binding.window.scroll_revision == self.revision) + return ( + super().current() + and not self.owner.closed + and self.binding.window.scroll_revision == self.revision + ) def refresh(self, publication): if not self.current(): return if publication is None: - if (destination := HeldReaderDestination.held(self.binding.window)) is not None: + if ( + destination := HeldReaderDestination.held(self.binding.window) + ) is not None: destination.release(self.binding.window) else: self.owner.source_requests.submit(publication) @@ -492,22 +566,32 @@ def reader(self): return self.view.app.coordination_access.transcripts def current(self): - return (self.view.is_attached and not self.view.is_leaving - and self.window.is_attached and self.contents.is_attached) + return ( + self.view.is_attached + and not self.view.is_leaving + and self.window.is_attached + and self.contents.is_attached + ) def histories(self): - return tuple(history for history in self.contents.query_children(TranscriptHistory) - if not history.is_leaving) + return tuple( + history + for history in self.contents.query_children(TranscriptHistory) + if not history.is_leaving + ) @property def history(self) -> TranscriptHistory: - history, = self.histories() + (history,) = self.histories() return history def observe_thread(self, owner): view = self.view owner._thread_observation.enter_context( - self.thread_scope.observe(view, view.app.coordination_access, view.thread_observed)) + self.thread_scope.observe( + view, view.app.coordination_access, view.thread_observed + ) + ) @property def reports_coverage(self): @@ -529,7 +613,9 @@ def replace_source(self): async def replace(): await retirement - replacement = TranscriptHistory.empty(view=self.view, notification_view=self.contents) + replacement = TranscriptHistory.empty( + view=self.view, notification_view=self.contents + ) removal = history.remove() # Both native membership changes happen before yielding: callbacks # see one active reader while the old tree finishes pruning. @@ -543,7 +629,7 @@ async def restore_documents(self, owner): if not self.view.is_attached or not owner.documents: return moment = self.Moment.capture(self, owner) - source, = owner.documents + (source,) = owner.documents if not moment.current() or source.closed: return await self.history.restore_source(source, moment.current) @@ -555,7 +641,9 @@ async def restore(self, owner): view.resume_retained_history() if not owner.readable: return - moment = self.Restoration.capture(self, owner, revision=view.window.scroll_revision) + moment = self.Restoration.capture( + self, owner, revision=view.window.scroll_revision + ) position = self.window.reader_position history = self.history if not await history.restore_reader_ranges(position.ranges, moment.current): @@ -592,33 +680,59 @@ def request(self, owner): view = self.view if not view.is_attached: return None - owner.worker = view.run_worker(partial(owner.publish, CommitPublication), - group="transcript-window", exclusive=True) + owner.worker = view.run_worker( + partial(owner.publish, CommitPublication), + group="transcript-window", + exclusive=True, + ) return owner.worker def cancel_workers(self): view = self.view - return tuple(worker for group in ("transcript-window", "transcript-source", "transcript-handling") - for worker in view.workers.cancel_group(view, group)) + return tuple( + worker + for group in ( + "transcript-window", + "transcript-source", + "transcript-handling", + ) + for worker in view.workers.cancel_group(view, group) + ) def covered(self, owner, coverage, history): view = self.view contents = self.contents if history.is_attached and history.parent is contents: owner.events.publish(coverage) - candidates = retirement_candidates(contents.children, CommitEvidence( - frozenset(), - frozenset(coverage.sequences) | history.covered_sequences(frozenset( - sequence for child in contents.children if isinstance(child, CommitParticipant) - for sequence in child.commit_claim.required_sequences)), - history, coverage.native_inputs, history.outgoing_coverage, - )) + candidates = retirement_candidates( + contents.children, + CommitEvidence( + frozenset(), + frozenset(coverage.sequences) + | history.covered_sequences( + frozenset( + sequence + for child in contents.children + if isinstance(child, CommitParticipant) + for sequence in child.commit_claim.required_sequences + ) + ), + history, + coverage.native_inputs, + history.outgoing_coverage, + ), + ) protected = protected_blocks(view, candidates) - retirement = owner.retire_presentations(contents, view.output, - [child for child in candidates if child not in protected]) + retirement = owner.retire_presentations( + contents, + view.output, + [child for child in candidates if child not in protected], + ) + async def complete(): await retirement view.query_one(SessionDetails)._refresh_summary() + return AwaitComplete(complete()) return AwaitComplete.nothing() @@ -667,12 +781,16 @@ def _unbind_view(self) -> None: def readable(self) -> bool: return self.binding.thread_scope.readable - async def get_transcript_page(self, *, before=None, after=None, through=None) -> TranscriptPage: + async def get_transcript_page( + self, *, before=None, after=None, through=None + ) -> TranscriptPage: frame = self.binding.capture(self, BoundFrame) scope = self.binding.thread_scope if frame is None: raise ValueError("Transcript paging requires a bound thread") - page = await frame.binding.reader.page(scope, before=before, after=after, through=through) + page = await frame.binding.reader.page( + scope, before=before, after=after, through=through + ) if self.binding.thread_scope != scope: raise ValueError("Thread source changed while reading transcript") return page @@ -681,7 +799,6 @@ async def get_transcript_page(self, *, before=None, after=None, through=None) -> def retained_source_bytes(self) -> int: return sum(source.retained_source_bytes for source in self.documents) - def received(self, page: TranscriptPage) -> None: """A read page resumes parked history; a commit adds what the history lacks.""" history = self.binding.history @@ -709,16 +826,12 @@ def invalidate_publications(self) -> None: """Revoke in-flight view work without forgetting its published source.""" self.generation += 1 - - - async def publish(self, kind: type[TranscriptPublication], *args) -> bool: publication = self.binding.capture(self, kind, *args) if publication is not None and publication.current(): return await publication.publish() return True - def changed(self, cursor: TranscriptCursor | None) -> None: self.invalidate_publications() self.dirty = True @@ -745,7 +858,9 @@ async def mark_read(self, through, acknowledge, selected) -> None: except TranscriptChanged as error: if selected(): self.require_commit() - self.binding.view.app.show_error(error, "Saved history reloading", severity="warning") + self.binding.view.app.show_error( + error, "Saved history reloading", severity="warning" + ) async def refresh_cursor(self) -> TranscriptCursor: """Commit and return the cursor from the resulting painted transcript.""" @@ -760,7 +875,9 @@ async def refresh_cursor(self) -> TranscriptCursor: if not frame.current(): raise TranscriptChanged() ready = asyncio.get_running_loop().create_future() - frame.binding.view.call_after_refresh(lambda: ready.set_result(self.displayed_cursor)) + frame.binding.view.call_after_refresh( + lambda: ready.set_result(self.displayed_cursor) + ) cursor = await ready if cursor is None: raise TranscriptChanged() @@ -772,7 +889,11 @@ def retry(self) -> None: if not self.dirty or self.worker is not None and not self.worker.is_finished: return publication = self.binding.capture(self, CommitPublication) - if publication is not None and publication.native_current() and publication.ready_to_commit(): + if ( + publication is not None + and publication.native_current() + and publication.ready_to_commit() + ): self.binding.request(self) def source_work_finished(self, history) -> None: @@ -816,7 +937,6 @@ async def suspend(self) -> None: except WorkerCancelled: pass - def retire_presentations(self, contents, output, candidates) -> AwaitComplete: """Withdraw covered native paint now; join teardown outside source locks. @@ -903,7 +1023,11 @@ async def detach(self, view: Conversation) -> None: history = self.binding.history if history._source_state.retirement_source().reports_coverage: documents.append(await history.park_document()) - if self.closed or not self.binding.matches(view) or self.generation != generation: + if ( + self.closed + or not self.binding.matches(view) + or self.generation != generation + ): for source in documents: source.close() return diff --git a/src/toad/transcript_state.py b/src/toad/transcript_state.py index 3b047137e..86af13c41 100644 --- a/src/toad/transcript_state.py +++ b/src/toad/transcript_state.py @@ -14,7 +14,10 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable - from toad.transcript_source_preparation import HistorySourceSnapshot, TranscriptSourcePreparation + from toad.transcript_source_preparation import ( + HistorySourceSnapshot, + TranscriptSourcePreparation, + ) from toad.widgets.committed_presentation import CommitParticipant from toad.widgets.history_anchor import LatestPosition @@ -49,7 +52,9 @@ def delivered(self, owner) -> None: def arriving(self, prepared, followed_end: bool): """The staged range begins at the edge this append advances.""" - return prepared.fragments[prepared.start:prepared.stop] if followed_end else () + return ( + prepared.fragments[prepared.start : prepared.stop] if followed_end else () + ) def mounted(self, owner) -> None: """An already-read source needs no initial destination request.""" @@ -86,7 +91,11 @@ def observed(self, widget: Widget) -> "TranscriptState": def for_projection(self, projection, owner) -> "TranscriptState": """A view may publish only while its source still owns that projection.""" - if owner is None or not owner.state.can_draw or not owner.filter.owns_projection(projection): + if ( + owner is None + or not owner.state.can_draw + or not owner.filter.owns_projection(projection) + ): return RetiredProjectionTranscript(self) return self @@ -123,8 +132,12 @@ def check_edges(self, owner) -> None: @classmethod def successors(cls): - return (LiveTranscript, ParkedSourceTranscript, DetachedTranscript, - ClosingTranscript) + return ( + LiveTranscript, + ParkedSourceTranscript, + DetachedTranscript, + ClosingTranscript, + ) def publish(self) -> TranscriptState: return self @@ -207,8 +220,9 @@ def prepare(self, owner) -> None: def resume(self, owner) -> LatestViewportState: """Return the request state after this draw's source-work admission.""" - if (not self.current(owner.window) - or owner.schedule_source_work(partial(owner.jump_latest, self))): + if not self.current(owner.window) or owner.schedule_source_work( + partial(owner.jump_latest, self) + ): return NoLatestRequest() return self @@ -298,8 +312,13 @@ def can_draw(self) -> bool: @classmethod def successors(cls): - return (LiveTranscript, ProvisionalTranscript, RetiredSourceTranscript, - ClosingTranscript, DetachedTranscript) + return ( + LiveTranscript, + ProvisionalTranscript, + RetiredSourceTranscript, + ClosingTranscript, + DetachedTranscript, + ) def defer_source_work(self, owner, work) -> None: """The existing observation scope owns the paused operation's lifetime.""" @@ -308,7 +327,10 @@ def defer_source_work(self, owner, work) -> None: owner._source_observation.enter_context(waiting.observe()) async def execute(self, owner, work): - from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision + from agent_comms.coordination_errors import ( + CoordinationReadUnavailable, + StaleRevision, + ) with ExitStack() as completion: completion.callback(owner.finish_source_work, self) @@ -422,9 +444,14 @@ def __init__(self, source: TranscriptState, owner, live_blocks: tuple[Widget, .. def arriving(self, prepared, followed_end: bool): # Rows its live blocks already showed have not arrived again; every other row has. # The live blocks are the commit participants `retirement_candidates` chose. - claims = tuple(cast("CommitParticipant", block).commit_claim for block in self.live_blocks) - return tuple(fragment for fragment in super().arriving(prepared, followed_end) - if not any(fragment.shown_live_by(claim) for claim in claims)) + claims = tuple( + cast("CommitParticipant", block).commit_claim for block in self.live_blocks + ) + return tuple( + fragment + for fragment in super().arriving(prepared, followed_end) + if not any(fragment.shown_live_by(claim) for claim in claims) + ) def rows_published(self, owner) -> None: if not NEWER_END.visible_more(owner): diff --git a/src/toad/widgets/channel_activity.py b/src/toad/widgets/channel_activity.py index fce2d07ff..73af96105 100644 --- a/src/toad/widgets/channel_activity.py +++ b/src/toad/widgets/channel_activity.py @@ -14,7 +14,9 @@ class ChannelActivity(SidebarModelView, KeyedRows, VerticalGroup): def __init__(self, view): self.view = view self._members = VerticalGroup() - self.details = widgets.Collapsible(self._members, title="0 active", collapsed=True) + self.details = widgets.Collapsible( + self._members, title="0 active", collapsed=True + ) self.details.styles.padding = 0 super().__init__() self.styles.overflow_y = "auto" @@ -29,7 +31,11 @@ def member_container(self): @property def rows(self): - return {row.target.name: row for row in self.member_container.children if not row.is_leaving} + return { + row.target.name: row + for row in self.member_container.children + if not row.is_leaving + } @property def members(self): @@ -40,8 +46,9 @@ def member_keys(self): return tuple(row.name for row in self.members) def accepts_observation(self) -> bool: - return (self.view.query_ancestor(SessionScope).is_current - and self.model_state.serves(self.view._bound_root)) + return self.view.query_ancestor( + SessionScope + ).is_current and self.model_state.serves(self.view._bound_root) def model_replaced(self) -> None: self.member_container.remove_children() @@ -52,7 +59,8 @@ def sync(self, *, change: SidebarChange, reorder: bool = False) -> None: def create_row(self, name): row = self.view.conversation.activity_details(ThreadTarget(name)) row.thread = StatusThread.for_root( - self.model_state.root(), name, self.app.coordination_access) + self.model_state.root(), name, self.app.coordination_access + ) return row def sync_members(self, changed: frozenset[str]) -> None: diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index dd7484846..7daf96a37 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -75,9 +75,13 @@ def __init__( # The observer's announcement the last refresh began from. self._refreshed_from = None self._bound_root = Path(wire_root).resolve() if wire_root is not None else None - self.input_histories.bind_scope(f"comms:{FieldCodec.encode(type(conversation))}:{conversation.target.name}") + self.input_histories.bind_scope( + f"comms:{FieldCodec.encode(type(conversation))}:{conversation.target.name}" + ) self.message_contents = Contents(id="contents") - self.message_history = WireHistory.empty(view=self, notification_view=self.message_contents) + self.message_history = WireHistory.empty( + view=self, notification_view=self.message_contents + ) def transcript_binding(self) -> ViewBinding: """A wire conversation has no native transcript to publish.""" @@ -106,16 +110,22 @@ async def initialize_view(self) -> None: # Reuse the canonical core service already shared by tab sidebars and # transcript readers. Its revision-aware caches remain model-owned. shared = await self.app.preparation.run_thread( - lambda: self.app.coordination_access.observation.observed().service) + lambda: self.app.coordination_access.observation.observed().service + ) if self._bound_root is not None and self._bound_root != shared.root: raise ValueError( f"Conversation root {self._bound_root} differs from observed root {shared.root}" ) comms = shared - self.message_history.open(WireSource( - comms, self.conversation, Path(self.project_path), self._me, - IrcMessageStyle(), - )) + self.message_history.open( + WireSource( + comms, + self.conversation, + Path(self.project_path), + self._me, + IrcMessageStyle(), + ) + ) self.agent_info = Content(self.conversation.label()) self.refresh_thread_status() self.prepare_prompt() @@ -148,8 +158,9 @@ async def on_transcript_source_work_finished(self, event) -> None: if event.publisher is not history: return self.settle_user_message() - if (not history.state.accepts_source_work - or not source.uses_observation(access.observation.observed())): + if not history.state.accepts_source_work or not source.uses_observation( + access.observation.observed() + ): return if self._refreshed_from != access.revision: # The observer announced a change while this work ran. The history @@ -167,9 +178,12 @@ async def on_transcript_source_work_finished(self, event) -> None: # itself before reading again, rather than rereading an already # current page until the observer's next tick. revision = await source.read_revision() - if (source is history.page_source and snapshot.current(history) - and source.uses_observation(access.observation.observed()) - and history.revision.stores != revision): + if ( + source is history.page_source + and snapshot.current(history) + and source.uses_observation(access.observation.observed()) + and history.revision.stores != revision + ): await self._refresh() def prepare_prompt(self) -> None: @@ -204,7 +218,9 @@ def transcript_scope(self) -> NoTranscript: def status_thread(self) -> StatusThread: """Peer status does not grant access to the peer's full transcript.""" - return self.message_history.page_source.status_thread(self.app.coordination_access) + return self.message_history.page_source.status_thread( + self.app.coordination_access + ) async def _refresh(self) -> Worker | None: history = self.message_history @@ -214,7 +230,10 @@ async def _refresh(self) -> Worker | None: async def _refresh_source(self) -> None: """Read what changed in the conversation and draw it: the history's source work.""" - from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision + from agent_comms.coordination_errors import ( + CoordinationReadUnavailable, + StaleRevision, + ) try: history = self.message_history @@ -241,20 +260,27 @@ async def _refresh_source(self) -> None: self._refreshed_from = announced # A sent message may now be drawn: composing resumes before mentions are read. self.settle_user_message() - except (CoordinationReadUnavailable, StaleRevision): + except CoordinationReadUnavailable, StaleRevision: raise # Composer mentions and DM status read the observer's snapshot # and the agent records, which can take seconds on a busy bus. They are # not this conversation's messages, so they run after this work ends # and the history can load pages while they wait. - self.run_worker(partial(self._refresh_labels, source, changed, newly_announced, read_only), - group=self.LABELS_GROUP) + self.run_worker( + partial(self._refresh_labels, source, changed, newly_announced, read_only), + group=self.LABELS_GROUP, + ) LABELS_GROUP = "chat-labels" - async def _refresh_labels(self, source, changed: bool, newly_announced: bool, read_only: bool) -> None: + async def _refresh_labels( + self, source, changed: bool, newly_announced: bool, read_only: bool + ) -> None: """Refresh composer mentions and status for the history as it is drawn now.""" - from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision + from agent_comms.coordination_errors import ( + CoordinationReadUnavailable, + StaleRevision, + ) history = self.message_history snapshot = history.source_snapshot() @@ -269,23 +295,31 @@ async def _refresh_labels(self, source, changed: bool, newly_announced: bool, re if changed: if not await history.source_is_current(snapshot): return - if (not self.is_attached or source is not history.page_source - or not self.query_ancestor(SessionScope).is_current): + if ( + not self.is_attached + or source is not history.page_source + or not self.query_ancestor(SessionScope).is_current + ): return self.call_after_refresh(history.mark_visible) if not changed: return status = await source.status_text(read_only=read_only) - except (CoordinationReadUnavailable, StaleRevision): + except CoordinationReadUnavailable, StaleRevision: # The coordination store is busy or moved on: the observer's next # announcement refreshes this view and reads the labels again. return - if not await history.source_is_current(snapshot) or self.conversation != conversation: + if ( + not await history.source_is_current(snapshot) + or self.conversation != conversation + ): return await self.refresh_composer(read_only, status) if self.window.follows_tail: self.window.anchor() - if NEWER_END.visible_more(history) or (OLDER_END.visible_more(history) and self.window.max_scroll_y == 0): + if NEWER_END.visible_more(history) or ( + OLDER_END.visible_more(history) and self.window.max_scroll_y == 0 + ): history.request_preparation() async def composer_read_only(self) -> bool: @@ -296,7 +330,9 @@ async def command_target_context(self): return self.message_history.page_source.command_context(self) async def submit_input(self, event: input_events.UserInputSubmitted) -> None: - if event.body.strip().startswith("/") and await CommandCatalog.execute(event.body.strip(), self): + if event.body.strip().startswith("/") and await CommandCatalog.execute( + event.body.strip(), self + ): return if not event.body.strip(): return @@ -304,7 +340,9 @@ async def submit_input(self, event: input_events.UserInputSubmitted) -> None: async def _show_sent(self, sent: WireMessage, body: str) -> None: """Follow the conversation's end and read it now; composing resumes once `sent` is drawn.""" - self.run_worker(partial(self.input_histories.prompt.record, body), group="history") + self.run_worker( + partial(self.input_histories.prompt.record, body), group="history" + ) self.window.jump_to_latest() await self._refresh() self.settle_user_message() diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 130b95bb8..dd7bab49e 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -1,4 +1,5 @@ """A bounded, read-only Tree projection of the selected thread's context.""" + from __future__ import annotations import asyncio @@ -16,7 +17,10 @@ from agent_comms.context_store import ContextManifestCursor from toad.core.context_inspection import ( - ContextNode, DetachedInspection, HoldingInspection, InspectionState, + ContextNode, + DetachedInspection, + HoldingInspection, + InspectionState, ) from toad.core.events import CoordinationObserved, SessionSelected from toad.core_event_carrier import CoreEventMessage @@ -33,6 +37,7 @@ class ContextTreeIntent: The mounted Tree owns navigation; selected is the detail source, not its cursor. """ + expanded: set[str] = field(default_factory=set) selected: ContextNode | None = None query: str = "" @@ -87,16 +92,22 @@ def on_unmount(self): def present(self, groups): # Reconcile the original TreeNodes so Tree._build rebases its cursor. - with self.prevent(Tree.NodeExpanded, Tree.NodeCollapsed, Tree.NodeSelected, - Tree.NodeHighlighted): + with self.prevent( + Tree.NodeExpanded, + Tree.NodeCollapsed, + Tree.NodeSelected, + Tree.NodeHighlighted, + ): self.root.set_label("Context") labels = {label for label, _, _ in groups} for node in tuple(self.root.children): if self.owns_node(node) or node.label.plain not in labels: self._retire(node) for index, (label, models, expanded) in enumerate(groups): - group = next((node for node in self.root.children - if node.label.plain == label), None) + group = next( + (node for node in self.root.children if node.label.plain == label), + None, + ) if group is None: group = self.root.add(label, before=index, expand=expanded) self._reconcile(group, models) @@ -109,7 +120,9 @@ def search_results(self, matches, description, query): self.root.expand() for model in matches: self._add(self.root, model) - self.restore_detail("Select a matching original source to read its full public text.") + self.restore_detail( + "Select a matching original source to read its full public text." + ) def _reconcile(self, parent, models): previous = {node.data.key: node for node in parent.children} @@ -162,8 +175,13 @@ def resolve_reader(self, key): return node def _add(self, parent, model, *, before=None): - node = parent.add(model.label, model, before=before, allow_expand=True, - expand=model.key in self.intent.expanded) + node = parent.add( + model.label, + model, + before=before, + allow_expand=True, + expand=model.key in self.intent.expanded, + ) self.context_nodes[model.key] = node if node.is_expanded: self._expand(node) @@ -205,8 +223,11 @@ def node_collapsed(self, event): def selected(self, model): node = self.context_nodes.get(model.key) - return (self.owns_node(node) and node.data is model - and self.intent.selected is model) + return ( + self.owns_node(node) + and node.data is model + and self.intent.selected is model + ) class ContextExplorer(ShowsThreadIdentity, SidebarVisibilityObserver, Vertical): @@ -221,20 +242,28 @@ class ContextExplorer(ShowsThreadIdentity, SidebarVisibilityObserver, Vertical): ContextExplorer > TextArea.-maximized { height: 1fr; width: 1fr; } """ BINDINGS = [("r", "refresh", "Refresh context")] + def __init__(self, *, intents: dict[SessionIdentity, ContextTreeIntent]): super().__init__() self.state = DetachedInspection() self._intents = intents - self.search_field = Input(placeholder="Search current context or selected recorded request · Enter", - id="context-search") + self.search_field = Input( + placeholder="Search current context or selected recorded request · Enter", + id="context-search", + ) @property def intent(self) -> ContextTreeIntent: - return self._intents.setdefault(self.thread_view.thread_identity, ContextTreeIntent()) + return self._intents.setdefault( + self.thread_view.thread_identity, ContextTreeIntent() + ) def compose(self): - yield Static("Context · select a segment to inspect", markup=False, - classes="context-status") + yield Static( + "Context · select a segment to inspect", + markup=False, + classes="context-status", + ) self.search_field.value = self.intent.query yield self.search_field with Horizontal(classes="context-controls"): @@ -246,9 +275,15 @@ def compose(self): yield Button("Newer requests", id="context-newer", disabled=True) yield Button("Older requests", id="context-older", disabled=True) yield ContextTree(self._show_detail, self._show_placeholder) - yield ProcessTextArea("No context selected.", soft_wrap=True, - show_line_numbers=False, id="context-detail") - yield Input(placeholder="Export to a new text file · path", id="context-export-path") + yield ProcessTextArea( + "No context selected.", + soft_wrap=True, + show_line_numbers=False, + id="context-detail", + ) + yield Input( + placeholder="Export to a new text file · path", id="context-export-path" + ) yield Button("Export selected text + source", id="context-export") def on_mount(self): @@ -261,9 +296,12 @@ def context_changed(self, message: CoreEventMessage): self._observed() def presentation_visible(self): - return (self.is_attached and self.query_ancestor(SessionScope).is_current - and not self.query_ancestor(SideBar).collapsed - and not self.query_ancestor(SideBarCollapsible).collapsed) + return ( + self.is_attached + and self.query_ancestor(SessionScope).is_current + and not self.query_ancestor(SideBar).collapsed + and not self.query_ancestor(SideBarCollapsible).collapsed + ) def on_show(self): self.call_after_refresh(self._observed) @@ -283,8 +321,11 @@ def _observed(self, _value=None): @on(Worker.StateChanged) def context_read_finished(self, event): - if (event.worker.node is self and event.worker.group == "context-read" - and event.state == WorkerState.SUCCESS): + if ( + event.worker.node is self + and event.worker.group == "context-read" + and event.state == WorkerState.SUCCESS + ): self._observed() def on_unmount(self): @@ -295,7 +336,9 @@ def identity_changed(self, identity: SessionIdentity) -> None: self.state = identity.inspection_state() self.search_field.value = self.intent.query self.query_one(ContextTree).clear() - self.query_one(".context-status", Static).update(f"Context · {identity.display_thread()}") + self.query_one(".context-status", Static).update( + f"Context · {identity.display_thread()}" + ) self._show_placeholder("No context selected.") self.action_refresh() @@ -304,18 +347,24 @@ def action_refresh(self): self._read(force=True) def _reading(self, identity: SessionIdentity): - return (self.is_attached and not get_current_worker().is_cancelled - and self.thread_view.thread_identity == identity) + return ( + self.is_attached + and not get_current_worker().is_cancelled + and self.thread_view.thread_identity == identity + ) def _working(self, group): - return any(worker.node is self and worker.group == group - and not worker.is_finished for worker in self.workers) + return any( + worker.node is self and worker.group == group and not worker.is_finished + for worker in self.workers + ) @work(group="context-read", exclusive=True) async def _read(self, *, force=False): await self.thread_view.thread_identity.inspect_owner( lambda identity: self._read_owner(identity, force=force), - self.query_one(".context-status", Static).update) + self.query_one(".context-status", Static).update, + ) async def _read_owner(self, identity: ThreadIdentity, *, force=False): status = self.query_one(".context-status", Static) @@ -324,22 +373,32 @@ async def _read_owner(self, identity: ThreadIdentity, *, force=False): self.state = previous.observe(access.revision) try: service = await self.app.preparation.run_thread( - lambda: access.require(RouteSelection.capture(identity.root_path())).service) + lambda: access.require( + RouteSelection.capture(identity.root_path()) + ).service + ) if not self._reading(identity): return - acquired = await self.state.acquire(service, access.revision, identity, - cursor=self.intent.cursor) + acquired = await self.state.acquire( + service, access.revision, identity, cursor=self.intent.cursor + ) if not self._reading(identity): return self.state = self.state.receive_inspection(acquired) status.update(self.state.status) - self.state.present(lambda current: self._inspection_acquired(previous, current, force)) + self.state.present( + lambda current: self._inspection_acquired(previous, current, force) + ) except asyncio.CancelledError: raise def _inspection_acquired(self, previous, current, force): - self.query_one("#context-older", Button).disabled = not current.inspection.page.has_older() - self.query_one("#context-newer", Button).disabled = not current.inspection.page.has_newer() + self.query_one("#context-older", Button).disabled = ( + not current.inspection.page.has_older() + ) + self.query_one("#context-newer", Button).disabled = ( + not current.inspection.page.has_newer() + ) if previous.inspection_differs(current.inspection): current.present_acquired(previous, self._present) if previous.needs_native(current.inspection, force): @@ -358,10 +417,14 @@ async def _read_native(self, captured: HoldingInspection): raise except RequestError as error: if self._reading(captured.thread_identity): - self._native_acquired(self.state.native_failed(captured.inspection, error)) + self._native_acquired( + self.state.native_failed(captured.inspection, error) + ) else: if self._reading(captured.thread_identity): - self._native_acquired(self.state.with_native(captured.inspection, native)) + self._native_acquired( + self.state.with_native(captured.inspection, native) + ) def _native_acquired(self, updated): if updated is not self.state: @@ -387,10 +450,16 @@ def action_correct(self, event): if not answers: self.notify("Select an original annotation to correct", severity="warning") return - show_target_menu(self.screen, event.button.region.offset, "Correct original answer", + show_target_menu( + self.screen, + event.button.region.offset, + "Correct original answer", [(answer.declared_name, answer.public_title()) for answer in answers], - {answer.declared_name: partial(self._correct_annotation, model, answer) - for answer in answers}) + { + answer.declared_name: partial(self._correct_annotation, model, answer) + for answer in answers + }, + ) @work(group="context-correction", exclusive=True) async def _correct_annotation(self, model, answer): @@ -415,8 +484,11 @@ async def _present(self, captured: HoldingInspection): # Parking retains this tree and its workers. Publish valid preparation # into that original tree even while hidden; unmount/identity changes # and new source/native resources still revoke the captured content. - if (self._reading(captured.thread_identity) and self.state.presentation_current(captured) - and not self.intent.query): + if ( + self._reading(captured.thread_identity) + and self.state.presentation_current(captured) + and not self.intent.query + ): self.query_one(ContextTree).present(groups) @on(Button.Pressed, "#context-older") @@ -470,13 +542,22 @@ async def _search(self, captured: HoldingInspection, query, selected): matches = await captured.find(query, selected) if not self._searching(captured, query, selected): return - description = selected.search_description() if selected is not None else "Current public context" + description = ( + selected.search_description() + if selected is not None + else "Current public context" + ) self.query_one(ContextTree).search_results(matches, description, query) - status.update(f"{len(matches)} matching sources · first 100 shown · {description}") + status.update( + f"{len(matches)} matching sources · first 100 shown · {description}" + ) def _searching(self, captured, query, selected): - return (self._reading(captured.thread_identity) and self.state.presentation_current(captured) - and self.intent.search_current(query, selected)) + return ( + self._reading(captured.thread_identity) + and self.state.presentation_current(captured) + and self.intent.search_current(query, selected) + ) @on(Button.Pressed, "#context-read-full") @work(group="context-full-read", exclusive=True) @@ -531,7 +612,9 @@ def node_highlighted(self, event): self._show_detail(event.node.data) def _selected(self, model): - return self.query_one(ContextTree).selected(model) if self.is_attached else False + return ( + self.query_one(ContextTree).selected(model) if self.is_attached else False + ) @work(group="context-detail", exclusive=True) async def _show_detail(self, model): diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c430a95c7..29ca929b6 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -60,7 +60,11 @@ from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.app import ToadApp -from toad.core.source_events import CurrentWorkingDirectoryChanged, TranscriptCoverage, TranscriptSourceWorkFinished +from toad.core.source_events import ( + CurrentWorkingDirectoryChanged, + TranscriptCoverage, + TranscriptSourceWorkFinished, +) from toad.input_history import InputHistories from toad.widgets.flash import Flash from toad.widgets.menu import Menu @@ -73,18 +77,30 @@ from agent_comms.ui_model.delivery import InputDelivery from agent_comms.goals import NoGoal from agent_comms.ui_model.goal import GoalDisplay -from toad.session_observation import GoalObservation, InputDeliveryObservation, SessionObservation +from toad.session_observation import ( + GoalObservation, + InputDeliveryObservation, + SessionObservation, +) from toad.widgets.goal_bar import GoalBar from toad.widgets.native_history import NativeHistory from toad.widgets.observed_thread_activity import ( - ObservedThreadActivity, ThreadStatusOwner, + ObservedThreadActivity, + ThreadStatusOwner, ) from toad.widgets.cli_draft import CliDraft from toad.status_thread import StatusThread from toad.widgets.transcript_history import JumpToLatest, TranscriptHistory from toad.widgets.session_details import OperationalSessionDetails, SessionDetails from toad.private_native_cursor import CursorStatus, HiddenCursorStatus -from toad.block_navigation import require_blocks, ConversationBlock, ConversationEntry, ContentNavigation, UpCursor, DownCursor +from toad.block_navigation import ( + require_blocks, + ConversationBlock, + ConversationEntry, + ContentNavigation, + UpCursor, + DownCursor, +) from functools import cached_property from toad.agent_presentation import AgentAttachmentView from toad.conversation_turn import TurnOwner, ConversationTurn @@ -105,7 +121,6 @@ AUTO_SESSION_TITLE_MAX_LENGTH = 50 - def make_session_title(prompt: str) -> str: """Create a compact deterministic title from a session's first prompt.""" title = " ".join(prompt.split()) @@ -120,7 +135,6 @@ def make_session_title(prompt: str) -> str: from toad.widgets.transcript_history import LineHistory - HELP_URL = "https://github.com/batrachianai/toad/discussions" INTERNAL_EROR = f"""\ @@ -142,7 +156,6 @@ def make_session_title(prompt: str) -> str: """ - class Loading(ConversationBlock, Static): """Tiny widget to show loading indicator.""" @@ -190,8 +203,12 @@ def can_draw_on_screen(self): return self.is_attached and self.is_on_screen def notification_targets(self) -> tuple[SourceNotifications, ...]: - return tuple(history for history in self.screen._compositor.visible_widgets - if isinstance(history, SourceNotifications) and history.notification_view is self) + return tuple( + history + for history in self.screen._compositor.visible_widgets + if isinstance(history, SourceNotifications) + and history.notification_view is self + ) @property def line_width(self) -> int: @@ -202,14 +219,24 @@ def line_width(self) -> int: def selected_categories(self) -> frozenset[type[MessageCategory]]: return all_categories() - async def prepare_history(self, history: LineHistory, current: Callable[[], bool], *, - window: HistoryWindow) -> None: + async def prepare_history( + self, + history: LineHistory, + current: Callable[[], bool], + *, + window: HistoryWindow, + ) -> None: """Supply a laid-out native target before its detached history prepares rows.""" if not self.line_width: await self.wait_for_refresh() rows, _limit = window.latest_limits(history.budget) - await history.prepare_rows(current, window=window, target=self, selected=self.selected_categories, - rows_needed=rows) + await history.prepare_rows( + current, + window=window, + target=self, + selected=self.selected_categories, + rows_needed=rows, + ) @height_dependency(INDEPENDENT_HEIGHT) def process_layout( @@ -222,14 +249,23 @@ class ConversationContents(CategorizedMount, Contents): """Conversation ordering and categories compose with the native row container.""" def notification_targets(self): - return (*super().notification_targets(), *(target - for child in self.screen._compositor.visible_widgets if child.parent is self - for target in child.notification_targets())) + return ( + *super().notification_targets(), + *( + target + for child in self.screen._compositor.visible_widgets + if child.parent is self + for target in child.notification_targets() + ), + ) @property def selected_categories(self) -> frozenset[type[MessageCategory]]: - return (self.query_ancestor(Conversation).visible_categories - if self.is_attached else all_categories()) + return ( + self.query_ancestor(Conversation).visible_categories + if self.is_attached + else all_categories() + ) class ContentsGrid(containers.Grid): @@ -247,7 +283,9 @@ class CursorContainer(Animated[Mix], Static): COMPONENT_CLASSES = {"cursor--selected"} def on_mount(self) -> None: - self.watch(self.query_ancestor(Window), "has_focus", self.app.colours.clock.refresh) + self.watch( + self.query_ancestor(Window), "has_focus", self.app.colours.clock.refresh + ) self.run_effect(CURSOR_BLINK) @property @@ -268,8 +306,12 @@ def render_lines(self, crop: Region) -> list[Strip]: style = self.get_component_rich_style("cursor--selected") selected_strip = Strip([Segment("▌", style)], cell_length=1) if CURSOR_BLINK in self.effects and self.animation_visible: - selected_strip = self.app.colours.paint_mix(self.effect_frame(CURSOR_BLINK), selected_strip) - for row in range(max(0, region.y - origin), min(crop.height, region.bottom - origin)): + selected_strip = self.app.colours.paint_mix( + self.effect_frame(CURSOR_BLINK), selected_strip + ) + for row in range( + max(0, region.y - origin), min(crop.height, region.bottom - origin) + ): strips[row] = selected_strip return strips @@ -305,7 +347,9 @@ def _arrange_scrollbars(self, region: Region): """Place the vertical bar after the viewport's trailing separator.""" for scrollbar, scrollbar_region in super()._arrange_scrollbars(region): if scrollbar is self.vertical_scrollbar: - scrollbar_region = scrollbar_region.translate((self.styles.padding.right, 0)) + scrollbar_region = scrollbar_region.translate( + (self.styles.padding.right, 0) + ) yield scrollbar, scrollbar_region def tail_follow_changed(self) -> None: @@ -338,8 +382,9 @@ def prepare_viewport(self) -> None: self.query_ancestor(Conversation).transcript.retry() - -class ConversationSessionBinding(WorkspaceHosted, ThreadStatusOwner, containers.Vertical): +class ConversationSessionBinding( + WorkspaceHosted, ThreadStatusOwner, containers.Vertical +): """Source-bound state and reusable rich-surface lifecycle shared by conversations.""" thread_presentation = None @@ -360,7 +405,9 @@ def transcript_scope(self) -> TranscriptScope: return self.query_ancestor(SessionScope).origin.transcript_scope() def status_thread(self) -> StatusThread: - return self.transcript.binding.thread_scope.status_thread(self.app.coordination_access) + return self.transcript.binding.thread_scope.status_thread( + self.app.coordination_access + ) def arm_thread_read(self) -> None: """Capture turn token and client authority for the next observed presentation.""" @@ -368,7 +415,9 @@ def arm_thread_read(self) -> None: def thread_observed(self, event: core_events.ThreadRowsChanged) -> None: """Consume this thread's one observed presentation.""" - presentation = self.app.coordination_access.thread_status.open(self.status_thread().name).presentation + presentation = self.app.coordination_access.thread_status.open( + self.status_thread().name + ).presentation read = self._thread_read self.arm_thread_read() read.settle(presentation).present(self.thread_presented) @@ -384,8 +433,11 @@ def thread_presented(self, presentation) -> None: return if self.query_ancestor(SessionScope).is_current: from toad.transcript_publication import ObservedSourcePublication + self._unpublished_presentation = None - self.transcript.source_requests.request(ObservedSourcePublication, presentation) + self.transcript.source_requests.request( + ObservedSourcePublication, presentation + ) else: self._unpublished_presentation = presentation @@ -398,8 +450,11 @@ async def _thread_view_selected(self, event: CoreEventMessage) -> None: presentation = self._unpublished_presentation if presentation is not None: from toad.transcript_publication import ObservedSourcePublication + self._unpublished_presentation = None - self.transcript.source_requests.request(ObservedSourcePublication, presentation) + self.transcript.source_requests.request( + ObservedSourcePublication, presentation + ) @handles(core_events.ObservedServiceChanged) async def _thread_route_observed(self, event: CoreEventMessage) -> None: @@ -420,7 +475,9 @@ def apply_bound_agent(self, agent: AgentBase) -> None: def agent_connected(self, agent: AgentBase) -> None: self.flash(Content.assemble(agent.get_info(), " connected"), style="success") if self._agent_data is not None: - self.app.application.usage.publish("agent-session-begin", agent=self._agent_data.identity) + self.app.application.usage.publish( + "agent-session-begin", agent=self._agent_data.identity + ) async def cancel_bound_turn(self, agent: AgentBase) -> None: self.flash("Cancelling agent turn…") @@ -439,13 +496,12 @@ def _turn_changed(self, owner: TurnOwner) -> None: busy_count = var(0) - - visible_categories: var[frozenset[type[MessageCategory]]] = var(lambda: all_categories(), init=False) - + visible_categories: var[frozenset[type[MessageCategory]]] = var( + lambda: all_categories(), init=False + ) project_path = var("") - working_directory: var[str] = var("") _shell: var[Shell | None] = var(None) @@ -453,6 +509,7 @@ def _turn_changed(self, owner: TurnOwner) -> None: agent_binding: var[AgentBinding] = var(NoAgentBound, bindings=True) agent_info: var[Content] = var(Content()) model_history_scope = var("") + @property def model_selection_available(self) -> bool: return self.agent_binding.configuration.model.available @@ -465,19 +522,14 @@ def mode_selection_available(self) -> bool: native_history_status: var[type[CursorStatus]] = var(lambda: HiddenCursorStatus) - goal_display: var[GoalDisplay] = var(NoGoal()) - goal_execution: var[ThreadGoalExecution] = var(IdleGoalExecution()) - status: var[str | Content | RichText] = var("") - title = var("") - def __init__( self, project_path: Path, @@ -489,20 +541,37 @@ def __init__( transcript: TranscriptPresentation | None = None, ) -> None: super().__init__() - self.turns = ConversationTurn(self._turn_changed, lambda: self.agent_binding.presentation.turns) + self.turns = ConversationTurn( + self._turn_changed, lambda: self.agent_binding.presentation.turns + ) self.throbber = BelowThrobber(self.working, id="throbber") self.throbber_above = AboveThrobber(self.working) - self._initialize_session(project_path, agent, agent_session_id, session_pk, - session_title, initial_prompt, transcript) - + self._initialize_session( + project_path, + agent, + agent_session_id, + session_pk, + 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, - transcript: TranscriptPresentation | None = None) -> None: + def _initialize_session( + self, + project_path, + agent=None, + agent_session_id=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) - self.set_reactive(ConversationSessionBinding.working_directory, str(project_path)) + self.set_reactive( + ConversationSessionBinding.working_directory, str(project_path) + ) self.output = LiveOutput(self) self._loading: Loading | None = None self._mcp_live_note: Note | None = None @@ -515,7 +584,8 @@ def _initialize_session(self, project_path, agent=None, agent_session_id=None, self._agent_data = agent self._thread_loading = ThreadLoading.initial_contents(agent) self.set_reactive( - ConversationSessionBinding.model_history_scope, agent.identity if agent else "" + ConversationSessionBinding.model_history_scope, + agent.identity if agent else "", ) self._agent_session_id = agent_session_id self._session_pk = session_pk @@ -529,9 +599,9 @@ def _initialize_session(self, project_path, agent=None, agent_session_id=None, self.project_data_path = paths.get_project_data(project_path) self.input_histories = InputHistories( - self.project_data_path, agent_session_id or ( - f"session-{session_pk}" if session_pk is not None else "" - ), + self.project_data_path, + agent_session_id + or (f"session-{session_pk}" if session_pk is not None else ""), ) self.session_start_time: float | None = None @@ -547,14 +617,18 @@ 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 = transcript if transcript is not None else TranscriptPresentation() + self.transcript = ( + transcript if transcript is not None else TranscriptPresentation() + ) self.cli_draft = CliDraft() self.tool_expansions: dict[str, bool] = {} # Both explicit eviction and Textual unmount consume this same lifetime. # The stack removes callbacks before calling them and continues on error. self._source_resources = AsyncResourceRelease() self._source_resources.push_async_callback( - SessionObservation.close_all, self.goal_observation, self.delivery_observation, + SessionObservation.close_all, + self.goal_observation, + self.delivery_observation, ) self._source_resources.callback(self.output.retire) self._source_resources.push_async_callback(self.transcript.detach, self) @@ -596,7 +670,9 @@ async def release_native_session(self) -> None: """ async with AsyncResourceRelease() as release: release.callback(self.take_initial_prompt) - release.callback(self.set_reactive, Conversation.agent_binding, NoAgentBound()) + release.callback( + self.set_reactive, Conversation.agent_binding, NoAgentBound() + ) await self._release_source_resources() def resume_retained_history(self) -> None: @@ -608,8 +684,6 @@ def refresh_native_projection(self) -> None: """Publish bound owner facts and invalidate the delivery read resource.""" self.agent_binding.refresh_projection(self) - - def start_native_session(self) -> None: self.agent_binding.start_session(self) @@ -629,13 +703,19 @@ async def session_ready(self) -> None: presentation = self.transcript if not presentation.binding.matches(self): return - if self._native_agent_started_here and (agent_data := self._agent_data) is not None: + if ( + self._native_agent_started_here + and (agent_data := self._agent_data) is not None + ): welcome = agent_data.welcome if welcome is not None: from toad.widgets.markdown_note import MarkdownNote await self.post(MarkdownNote(welcome)) - if self.transcript is not presentation or not presentation.binding.matches(self): + if ( + self.transcript is not presentation + or not presentation.binding.matches(self) + ): return self._native_agent_started_here = False if self.agent_binding.configuration.model.requires_selection: @@ -648,9 +728,7 @@ async def session_ready(self) -> None: input_events.UserInputSubmitted(prompt[1:], shell=True) ) else: - self.publish_core( - input_events.UserInputSubmitted(prompt, shell=False) - ) + self.publish_core(input_events.UserInputSubmitted(prompt, shell=False)) self.take_initial_prompt() @handles(core_events.ConfigurationChanged) @@ -708,11 +786,15 @@ def watch_agent_binding(self, binding: AgentBinding) -> None: from toad.delivery_failure_view import DeliveryFailureView -class Conversation(DeliveryFailureView, ConversationCommsHandlers, DeclaredWidgetActions, ConversationSessionBinding): +class Conversation( + DeliveryFailureView, + ConversationCommsHandlers, + DeclaredWidgetActions, + ConversationSessionBinding, +): ACTIONS = ConversationAction """Holds the agent conversation (input, output, and various controls / information).""" - BLANK = True MAX_LIVE_BLOCKS = 32 BINDING_GROUP_TITLE = "Conversation" @@ -747,7 +829,6 @@ class Conversation(DeliveryFailureView, ConversationCommsHandlers, DeclaredWidge ), ] - contents = getters.query_one("#contents", Contents) window = getters.query_one(Window) cursor = getters.query_one(CursorContainer) @@ -768,7 +849,9 @@ def visible_read_cursor(self): return through def watch_visible_categories( - self, previous: frozenset[type[MessageCategory]], selected: frozenset[type[MessageCategory]], + self, + previous: frozenset[type[MessageCategory]], + selected: frozenset[type[MessageCategory]], ) -> None: # The reader stays on the message they are reading; when the new # selection hides it, on the next message still shown. @@ -877,7 +960,8 @@ def compose(self) -> ComposeResult: yield Flash() with containers.Vertical(id="prompt-stack"): self.thread_activity = OperationalSessionDetails( - self, transcript=self.transcript, + self, + transcript=self.transcript, history=NativeHistory().data_bind( status=Conversation.native_history_status ), @@ -897,7 +981,9 @@ def compose(self) -> ComposeResult: status=Conversation.status, ) yield self.throbber - self.goal_bar: GoalBar = GoalBar(self, throbber=self.throbber, prompt=prompt) + self.goal_bar: GoalBar = GoalBar( + self, throbber=self.throbber, prompt=prompt + ) yield self.goal_bar yield prompt @@ -1018,17 +1104,19 @@ def cursor_block(self) -> ConversationEntry | None: def cursor_block_child(self) -> Widget | None: return self.navigation.selected - def draws_user_message(self, sent) -> bool: from agent_comms.message_reference import MessageReference reference = MessageReference(sent.seq, sent.message_id) - return any(reference in fragment.notification_references - for fragment in self.transcript.binding.history.visible_fragments()) + return any( + reference in fragment.notification_references + for fragment in self.transcript.binding.history.visible_fragments() + ) async def _apply_session_name(self, name: str) -> None: await self.agent_binding.set_session_name(name) self.publish_core(core_events.SessionTitleChanged(name)) + async def rename_session(self, name: str) -> None: """Apply an explicit user- or agent-provided title to this session.""" self._auto_title_eligible = False @@ -1043,9 +1131,7 @@ async def auto_name_from_prompt(self, prompt: str) -> None: await self._apply_session_name(make_session_title(prompt)) @handles(core_events.SessionInfoUpdate) - async def on_session_info_update( - self, message: CoreEventMessage - ) -> None: + async def on_session_info_update(self, message: CoreEventMessage) -> None: self._auto_title_eligible = False self.publish_core(core_events.SessionThreadTitle(message.event.title or "")) @@ -1067,7 +1153,9 @@ async def on_agent_fail(self, message: CoreEventMessage) -> None: details=message.event.details, ) - await self.agent_binding.session_disposition.present_failure(message.event, self) + await self.agent_binding.session_disposition.present_failure( + message.event, self + ) def on_work_started(self) -> None: self.busy_count += 1 @@ -1100,8 +1188,11 @@ async def action_provider_login(self) -> None: return method_id = await ContextMenu.choose( self.screen, - Offset(self.prompt.region.x, max(0, self.prompt.region.y - len(methods) - 4)), - "Connect a provider", [(method.id, method.name) for method in methods], + Offset( + self.prompt.region.x, max(0, self.prompt.region.y - len(methods) - 4) + ), + "Connect a provider", + [(method.id, method.name) for method in methods], ) if not method_id: self.prompt.focus() @@ -1116,7 +1207,9 @@ async def action_provider_login(self) -> None: command = command + (" " + shlex.join(arguments) if arguments else "") code = await self.app.push_screen_wait( ActionModal( - CatalogCommandAction.bind("login", Command(method.name, command)), + CatalogCommandAction.bind( + "login", Command(method.name, command) + ), agent.definition, command, env=method.env or {}, @@ -1171,19 +1264,25 @@ async def on_change_model(self, event: CoreEventMessage) -> None: # Recent-model persistence is optional metadata, not a prerequisite # for opening the model's already advertised thinking choices. - self.run_worker(DB().record_model_usage(self.model_history_scope, model.value, - last_used=time_ns())) + self.run_worker( + DB().record_model_usage( + self.model_history_scope, model.value, last_used=time_ns() + ) + ) levels = [choice.value for choice in agent.configuration.thinking.choices] if len(levels) > 1: level = await ContextMenu.choose( self.screen, - Offset(self.prompt.region.x, max(0, self.prompt.region.y - len(levels) - 4)), - f"Thinking level for {model.name}", [(value, value.title()) for value in levels], + Offset( + self.prompt.region.x, + max(0, self.prompt.region.y - len(levels) - 4), + ), + f"Thinking level for {model.name}", + [(value, value.title()) for value in levels], ) if ( level - and (error := await agent.set_thinking_level(level)) - is not None + and (error := await agent.set_thinking_level(level)) is not None ): self.app.show_error(error, "Set thinking level") return @@ -1219,8 +1318,11 @@ async def agent_turn_over(self, stop_reason: type[StopReason] | None) -> None: # Complete visible turn settlement independently of recent-model # database contention. The native worker owns this optional write. - self.run_worker(DB().record_model_usage(self.model_history_scope, model.value, - last_used=time_ns())) + self.run_worker( + DB().record_model_usage( + self.model_history_scope, model.value, last_used=time_ns() + ) + ) await self.output.settle() pending_loading, self._loading = self._loading, None if pending_loading is not None and pending_loading.is_attached: @@ -1231,13 +1333,17 @@ async def agent_turn_over(self, stop_reason: type[StopReason] | None) -> None: self._turn_count += 1 - if stop_reason is not None and stop_reason.note: from toad.widgets.markdown_note import MarkdownNote - await self.post(MarkdownNote( - stop_reason.note.replace('$AGENT', (self.agent_title or 'agent').title()), - classes='-stop-reason')) + await self.post( + MarkdownNote( + stop_reason.note.replace( + "$AGENT", (self.agent_title or "agent").title() + ), + classes="-stop-reason", + ) + ) if self.app.settings.notifications.turn_over: self.app.terminal_attention.notify( @@ -1249,16 +1355,17 @@ 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: event.stop() - if (self.query_ancestor(SessionScope).is_current and event.action is not None - and self.navigation.accepts_menu_owner(event.owner)): + if ( + self.query_ancestor(SessionScope).is_current + and event.action is not None + and self.navigation.accepts_menu_owner(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) @handles(CurrentWorkingDirectoryChanged) - def on_current_working_directory_changed( - self, event: CoreEventMessage - ) -> None: + def on_current_working_directory_changed(self, event: CoreEventMessage) -> None: if self._shell is None or self._shell.pending_directory is None: self.working_directory = str(Path(event.event.path).resolve().absolute()) @@ -1277,9 +1384,7 @@ async def on_update_status_line(self, message: CoreEventMessage): self.agent_binding.refresh_context(self) @handles(core_events.RejectedSessionUpdate) - async def on_rejected_session_update( - self, message: CoreEventMessage - ) -> None: + async def on_rejected_session_update(self, message: CoreEventMessage) -> None: self.output.boundary() await self.post( Note( @@ -1293,14 +1398,10 @@ async def _clear_mcp_live(self) -> None: await self._mcp_live_note.remove() self._mcp_live_note = None - @handles(core_events.McpClientStopped) - async def on_mcp_client_stopped( - self, message: CoreEventMessage - ) -> None: + async def on_mcp_client_stopped(self, message: CoreEventMessage) -> None: await self._clear_mcp_live() - @handles(core_events.Update) async def on_acp_agent_message(self, message: CoreEventMessage): message.stop() @@ -1316,14 +1417,17 @@ async def present_started_input(self, started) -> None: self.output.boundary() if started.text: if not self.transcript.covers_input(started.native_id): - await self.post(UserInput(started.text, claim=StartedInputClaim(started))) + await self.post( + UserInput(started.text, claim=StartedInputClaim(started)) + ) self.submissions.native_input_presented(started) - @handles(TranscriptCoverage) async def on_transcript_coverage(self, message: CoreEventMessage) -> None: message.stop() - await self.transcript.binding.covered(self.transcript, message.event, message.publisher) + await self.transcript.binding.covered( + self.transcript, message.event, message.publisher + ) @handles(TranscriptSourceWorkFinished) def on_transcript_source_work_finished(self, message: CoreEventMessage) -> None: @@ -1357,15 +1461,11 @@ async def on_acp_plan(self, message: CoreEventMessage): await self.post(Plan(message.event.entries)) @handles(core_events.ToolCall) - async def on_acp_tool_call_update( - self, message: CoreEventMessage - ): + async def on_acp_tool_call_update(self, message: CoreEventMessage): await message.publisher.controller.surface.present_tool(message, self) @handles(core_events.AvailableCommandsUpdate) - async def on_acp_available_commands_update( - self, message: CoreEventMessage - ): + async def on_acp_available_commands_update(self, message: CoreEventMessage): self.update_slash_commands() async def action_interrupt(self) -> None: @@ -1430,7 +1530,9 @@ def ask( else: notify_title = title notify_message = "\n".join(f" • {option.text}" for option in options) - self.app.terminal_attention.notify(notify_message, title=notify_title, sound="question") + self.app.terminal_attention.notify( + notify_message, title=notify_title, sound="question" + ) ask = Ask(title, options, get_content, callback) self.prompt.ask(ask) @@ -1443,14 +1545,21 @@ async def composer_read_only(self) -> bool: async def command_target_context(self): from toad.screens.main import MainScreen from toad.target_commands import TargetContext + screen = self.query_ancestor(MainScreen) nav = screen.navigation_context - comms = await self.app.preparation.run_thread(lambda: self.app.coordination_access.observation.observed().service) + comms = await self.app.preparation.run_thread( + lambda: self.app.coordination_access.observation.observed().service + ) if not self.is_attached or screen.navigation_context != nav: - raise ValueError('Command view changed during discovery') - return screen.thread_identity.command_target(lambda actor: TargetContext(self.app, comms, actor, actor, nav.project_path, nav.owner_mode)) + raise ValueError("Command view changed during discovery") + return screen.thread_identity.command_target( + lambda actor: TargetContext( + self.app, comms, actor, actor, nav.project_path, nav.owner_mode + ) + ) - @work(group='command-catalog', exclusive=True) + @work(group="command-catalog", exclusive=True) async def update_slash_commands(self) -> None: """Prepare choices only for the prompt that can actually paint them. @@ -1471,10 +1580,15 @@ async def read_command_catalog(self) -> CommandCatalog: navigation = source.navigation_context agent = self.agent_binding context = self.command_target_context() - catalog = await CommandCatalog.read(self.app, agent.presentation.commands, context) - if (not self.is_attached or source.navigation_context != navigation - or agent is not self.agent_binding): - raise ValueError('Command view changed during discovery') + catalog = await CommandCatalog.read( + self.app, agent.presentation.commands, context + ) + if ( + not self.is_attached + or source.navigation_context != navigation + or agent is not self.agent_binding + ): + raise ValueError("Command view changed during discovery") return catalog @handles(core_events.LinkContextChanged) @@ -1521,9 +1635,7 @@ async def initialize_view(self) -> None: self.window.anchor() @handles(core_events.InputDispositionsChanged) - async def on_input_dispositions_changed( - self, event: CoreEventMessage - ) -> None: + async def on_input_dispositions_changed(self, event: CoreEventMessage) -> None: self.delivery_observation.invalidate() @on(DetailAction.Activated, "#session-inspect") @@ -1551,7 +1663,6 @@ async def inspect_session(self, event: DetailAction.Activated) -> None: lambda value: setattr(details, "overview_text", value), ) - @handles(input_events.GoalControlActivated) async def on_goal_control(self, event: CoreEventMessage): event.stop() @@ -1646,8 +1757,11 @@ async def post[WidgetType: Widget]( if not self.contents.is_attached: return widget from toad.widgets.message_divider import AgentActivityDivider + if widget.starts_agent_activity(self._agent_activity_boundary): - await self.contents.mount(AgentActivityDivider(widget.message_category), widget) + await self.contents.mount( + AgentActivityDivider(widget.message_category), widget + ) else: await self.contents.mount(widget) @@ -1774,8 +1888,10 @@ def shell(self) -> Shell: shell_start = self.app.settings.shell.command_start shell_directory = self.working_directory self._shell = Shell( - shell_directory, terminal_size=self.get_terminal_dimensions(), - shell=shell_command, start=shell_start + shell_directory, + terminal_size=self.get_terminal_dimensions(), + shell=shell_command, + start=shell_start, ) self._shell.start(AttachedSurfaceBinding(self, self._shell.events)) return self._shell @@ -1846,7 +1962,9 @@ async def action_select_block(self) -> None: menu = Menu(block, menu_options, focus_owner=self.window) region = cursor.region - menu.offset = Offset(1, region.y if region is not None else self.window.region.y) + menu.offset = Offset( + 1, region.y if region is not None else self.window.region.y + ) await self.mount(menu) menu.focus() @@ -1908,9 +2026,7 @@ def refresh_block_cursor(self, show) -> 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._present_block_cursor, cursor, show - ) + self.call_after_refresh(self._present_block_cursor, cursor, show) else: self.window.anchor() self.prompt.focus() @@ -1919,9 +2035,12 @@ def refresh_block_cursor(self, show) -> None: def _present_block_cursor(self, cursor, show) -> None: # A later prompt, tab or source event may already have moved the cursor # off this block. - if (self.is_attached and not self.is_leaving - and self.query_ancestor(SessionScope).is_current - and self.navigation.cursor is cursor): + if ( + self.is_attached + and not self.is_leaving + and self.query_ancestor(SessionScope).is_current + and self.navigation.cursor is cursor + ): show(cursor) self.cursor.refresh() self.refresh_bindings() diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 5d4b750b4..c91421842 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -27,7 +27,11 @@ from toad.widgets.selection import HoverSelection, ColourSelection from toad.session_tracker import SidebarState from toad.sidebar_layout import ( - Side, SidebarGeometry, SidebarPlacement, SidebarPlacementAction, SidebarShift, + Side, + SidebarGeometry, + SidebarPlacement, + SidebarPlacementAction, + SidebarShift, ) from toad.widgets.sidebar_viewport import SidebarHeader, SidebarViewport @@ -46,11 +50,22 @@ class BarPresentation: compact: bool @classmethod - def from_placement(cls, placement: SidebarPlacement, *, collapsed: bool, - directions: dict[Side, SidebarShift], width: int) -> "BarPresentation": - return cls(placement.side, placement.order, placement.floating, collapsed, - tuple(type(action) for action in directions.values()), - all(action.available for action in directions.values()) and width - 4 < 21) + def from_placement( + cls, + placement: SidebarPlacement, + *, + collapsed: bool, + directions: dict[Side, SidebarShift], + width: int, + ) -> "BarPresentation": + return cls( + placement.side, + placement.order, + placement.floating, + collapsed, + tuple(type(action) for action in directions.values()), + all(action.available for action in directions.values()) and width - 4 < 21, + ) @dataclass(frozen=True) @@ -142,7 +157,9 @@ def __init__( def watch_collapsed(self, collapsed: bool) -> None: if self.is_attached: - self.query_ancestor(SideBar).navigation.panels_collapsed[str(self.title)] = collapsed + self.query_ancestor(SideBar).navigation.panels_collapsed[ + str(self.title) + ] = collapsed def compose(self) -> ComposeResult: if self.header_control is None: @@ -195,7 +212,9 @@ def __init__(self, collapsed: bool = False, *, right: bool = False) -> None: def set_collapsed(self, collapsed: bool) -> None: self.styles.dock = "left" if self.right else "right" self.offset = (0 if collapsed else 1 if self.right else -1, 0) - text = ("<" if collapsed else ">") if self.right else (">" if collapsed else "<") + text = ( + ("<" if collapsed else ">") if self.right else (">" if collapsed else "<") + ) if self.content != text: self.update(text, layout=False) tooltip = "Expand sidebar" if collapsed else "Collapse sidebar" @@ -270,7 +289,9 @@ def __init__(self) -> None: def sync(self, tab_order: TabOrder) -> None: unavailable = tab_order.history_target(self.direction) is None self.disabled = unavailable - self.tooltip = self.unavailable_tooltip if unavailable else self.available_tooltip + self.tooltip = ( + self.unavailable_tooltip if unavailable else self.available_tooltip + ) @property def primary_activation(self): @@ -304,15 +325,24 @@ def __init__(self) -> None: self.tooltip = "Main menu" def render(self): - return Content("\n".join("━" * self.content_size.width for _ in range(self.content_size.height))) + return Content( + "\n".join( + "━" * self.content_size.width for _ in range(self.content_size.height) + ) + ) @property def primary_activation(self): return self.open_menu def open_menu(self) -> None: - ContextMenu.show(self.screen, Offset(self.region.x, self.region.bottom), - "Toad", ApplicationAction.menu(), callback=self.chosen) + ContextMenu.show( + self.screen, + Offset(self.region.x, self.region.bottom), + "Toad", + ApplicationAction.menu(), + callback=self.chosen, + ) async def chosen(self, action: ApplicationAction | None) -> None: if action is not None: @@ -504,7 +534,9 @@ def on_click(self, event: Click) -> None: self.action_activate() -class SidebarResizeHandle(CapturedDrag, ColourSelection, widgets.Static, can_focus=True): +class SidebarResizeHandle( + CapturedDrag, ColourSelection, widgets.Static, can_focus=True +): """Drag the conversation-facing edge; the placement model owns the width.""" highlight_foreground = ForegroundColour @@ -516,8 +548,10 @@ class SidebarResizeHandle(CapturedDrag, ColourSelection, widgets.Static, can_foc } SidebarResizeHandle:ansi { color: ansi_green; } """ - BINDINGS = [Binding("left", "resize(-1)", "Resize left", show=False), - Binding("right", "resize(1)", "Resize right", show=False)] + BINDINGS = [ + Binding("left", "resize(-1)", "Resize left", show=False), + Binding("right", "resize(1)", "Resize right", show=False), + ] def __init__(self) -> None: super().__init__(id="sidebar-resize-handle") @@ -672,12 +706,24 @@ def schedule_hydration(self) -> None: @property def presentation_visible(self) -> bool: - return self.screen.is_current and self.display and all( - ancestor.display for ancestor in self.ancestors if isinstance(ancestor, Widget)) + return ( + self.screen.is_current + and self.display + and all( + ancestor.display + for ancestor in self.ancestors + if isinstance(ancestor, Widget) + ) + ) def _start_hydration(self) -> None: - if (self._panels_loaded or self._panels_loading or not self.is_attached - or not self.presentation_visible or self.is_leaving): + if ( + self._panels_loaded + or self._panels_loading + or not self.is_attached + or not self.presentation_visible + or self.is_leaving + ): return self._panels_loading = True self.run_worker(self._hydrate_panels(), group=self.PANEL_WORKER_GROUP) @@ -734,16 +780,21 @@ def _compose_panels(self) -> ComposeResult: navigation = self.navigation for panel in self.panels: yield SideBarCollapsible( - panel.widget, title=panel.title, + panel.widget, + title=panel.title, collapsed=navigation.panels_collapsed.get(panel.title, panel.collapsed), - classes="-flex" if panel.flex else "-fixed", id=panel.id, + classes="-flex" if panel.flex else "-fixed", + id=panel.id, header_control=panel.header_control, ) def _compose_controls(self) -> ComposeResult: yield self.width_slider yield containers.Grid( - *(SidebarAction(kind()) for kind in SidebarPlacementAction.members_with(SidebarPlacementAction)), + *( + SidebarAction(kind()) + for kind in SidebarPlacementAction.members_with(SidebarPlacementAction) + ), id="sidebar-layout-actions", ) @@ -765,20 +816,28 @@ def _present_controls(self) -> bool: width = geometry.width directions = app.sidebar_layout.directions(self.id) current = PresentedBar( - presentation=BarPresentation.from_placement(placement, collapsed=self.collapsed, - directions=directions, width=width), - geometry=geometry, width_percent=placement.width_percent, + presentation=BarPresentation.from_placement( + placement, collapsed=self.collapsed, directions=directions, width=width + ), + geometry=geometry, + width_percent=placement.width_percent, ) previous = self._presented_layout if current == previous: return False self._presented_layout = current - controls = self.query_one_optional("#sidebar-controls") if self._panels_loaded else None + controls = ( + self.query_one_optional("#sidebar-controls") + if self._panels_loaded + else None + ) slider = self.width_slider if previous is None or previous.presentation != current.presentation: self.right = placement.side == "right" self.set_class(self.right, "-right") - self.search_input.styles.margin = (0, 0, 0, 4) if self.right else (0, 4, 0, 0) + self.search_input.styles.margin = ( + (0, 0, 0, 4) if self.right else (0, 4, 0, 0) + ) if toggle := self.query_one_optional(SideBarToggle): toggle.right = self.right toggle.set_collapsed(self.collapsed) @@ -787,9 +846,15 @@ def _present_controls(self) -> bool: if controls is not None: controls.display = not self.collapsed actions = controls.query_one_optional("#sidebar-layout-actions") - buttons = {direction: controls.query_one_optional(f"#sidebar-{direction}", SidebarAction) - for direction in (*directions, "float")} - if actions is None or any(button is None for button in buttons.values()): + buttons = { + direction: controls.query_one_optional( + f"#sidebar-{direction}", SidebarAction + ) + for direction in (*directions, "float") + } + if actions is None or any( + button is None for button in buttons.values() + ): # Mounted parents can precede their composed action rows. self._presented_layout = None else: @@ -800,8 +865,14 @@ def _present_controls(self) -> bool: button.display = action.available button.tooltip = action.tooltip(direction) toggle_mode = buttons["float"] - toggle_mode.update("Push" if placement.floating else "Float", layout=False) - toggle_mode.tooltip = "Push conversation text" if placement.floating else "Float over conversation text" + toggle_mode.update( + "Push" if placement.floating else "Float", layout=False + ) + toggle_mode.tooltip = ( + "Push conversation text" + if placement.floating + else "Float over conversation text" + ) actions.set_class(current.presentation.compact, "-compact") if slider.value != placement.width_percent: slider.value = placement.width_percent @@ -824,7 +895,10 @@ def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: return app = cast("ToadApp", self.app) event.action.apply(app.sidebar_layout, self.id) - for bar in (app.workspace_chrome.channels, *app.workspace_sessions.source.sidebars()): + for bar in ( + app.workspace_chrome.channels, + *app.workspace_sessions.source.sidebars(), + ): bar._present_controls() app.workspace_chrome.refresh_layout() diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 686ffe72a..d09052bfc 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -28,7 +28,11 @@ from textual._measurement import FLOW_HEIGHT, height_dependency from agent_comms.mro_dispatch import handles -from toad.core.events import SessionSelected, CoordinationObserved, ThreadRowStateChanged +from toad.core.events import ( + SessionSelected, + CoordinationObserved, + ThreadRowStateChanged, +) from toad.core_event_carrier import CoreEventReceiver, CoreEventMessage from agent_comms.ui_model.changes import Changes from agent_comms.ui_model.sidebar import ChannelRowModel, SidebarModel @@ -46,7 +50,9 @@ class UnpaintedChannel(ChannelRowModel): """An unobserved channel has no painted fields or known members.""" def __init__(self, name: str): - super().__init__(name, "", "", UnpaintedUnread(), False, (), frozenset(), ThreadSort) + super().__init__( + name, "", "", UnpaintedUnread(), False, (), frozenset(), ThreadSort + ) class TargetTreeMember: @@ -112,8 +118,10 @@ def __init__(self, tree): self.sources.setdefault(key, {})[group] = None # A thread can have simultaneous views. Keep only the native rows # needed by unfilled model slots; a removed duplicate must retire. - self.sources = {key: dict.fromkeys(islice(groups, max(0, demand[key]))) - for key, groups in self.sources.items()} + self.sources = { + key: dict.fromkeys(islice(groups, max(0, demand[key]))) + for key, groups in self.sources.items() + } self.requests = requests @staticmethod @@ -122,6 +130,7 @@ def sync(method): def synchronized(view, *args, **kwargs): with view.reconciliation_tree.reconcile_members(): return method(view, *args, **kwargs) + return synchronized def finish(self): @@ -173,8 +182,12 @@ def place_row(self, row, index: int) -> None: def reconcile_keys(self, keys, *, changed: frozenset[str]): rows = self.rows retained = set(rows.values()) - previous = {row: index for index, row in enumerate( - child for child in self.member_container.children if child in retained)} + previous = { + row: index + for index, row in enumerate( + child for child in self.member_container.children if child in retained + ) + } wanted = set(keys) retired = [key for key in rows if key not in wanted and not self.keep_row(key)] for key in retired: @@ -197,10 +210,14 @@ def reconcile_keys(self, keys, *, changed: frozenset[str]): for row, index in positions.items(): if previous.get(row) != index: self.place_row(row, index) - live = tuple(child for child in self.member_container.children if child in positions) + live = tuple( + child for child in self.member_container.children if child in positions + ) reordered = bool(ordered) and live != ordered if reordered: - self.member_container.sort_children(key=lambda child: positions.get(child, len(positions))) + self.member_container.sort_children( + key=lambda child: positions.get(child, len(positions)) + ) if retired or mounted or reordered: self.rows_changed() return ordered @@ -226,8 +243,15 @@ class SidebarGroup[RowT: "CommsRow"](KeyedRows, VerticalGroup): _row_mounting = None """The worker mounting pending rows after the following frames.""" - def __init__(self, row, *, expanded: bool, controls=(), - disclosure_type=SidebarDisclosure, **kwargs): + def __init__( + self, + row, + *, + expanded: bool, + controls=(), + disclosure_type=SidebarDisclosure, + **kwargs, + ): super().__init__(**kwargs) self.row = row self.rows: dict[str, RowT] = {} @@ -260,7 +284,9 @@ def sync_members(self, changed: frozenset[str]) -> None: def watch_expanded(self, previous: bool, expanded: bool) -> None: self.disclosure.refresh(layout=False) if self.is_mounted: - self.member_container.set_display_constraint("members", self.members_visible) + self.member_container.set_display_constraint( + "members", self.members_visible + ) self.members_visibility_changed(previous, expanded) self.rows_changed() @@ -284,9 +310,16 @@ def navigation_rows(self): def rows_for_target(self, target): if not self.accepts_members(): return () - header = (self.row,) if self.navigation_header and self.row.target_name == target else () + header = ( + (self.row,) + if self.navigation_header and self.row.target_name == target + else () + ) member = self.rows.get(target) - return (*header, *((member,) if member is not None and member.is_navigation_row() else ())) + return ( + *header, + *((member,) if member is not None and member.is_navigation_row() else ()), + ) def accepts_navigation(self) -> bool: return self.display and self.accepts_members() @@ -359,8 +392,12 @@ def reconcile_rows(self, *, changed: frozenset[str]): self._rows_pending = False self._row_deadline = perf_counter_ns() + self.screen.LAYOUT_STEP_NS ordered = self.reconcile_keys(keys, changed=changed) - if self._rows_pending and (self._row_mounting is None or self._row_mounting.is_finished): - self._row_mounting = self.run_worker(self._mount_pending_rows(), group="sidebar-rows") + if self._rows_pending and ( + self._row_mounting is None or self._row_mounting.is_finished + ): + self._row_mounting = self.run_worker( + self._mount_pending_rows(), group="sidebar-rows" + ) return ordered async def _mount_pending_rows(self) -> None: @@ -591,7 +628,8 @@ async def on_mount(self) -> None: self.observe_core(self.app.events) self.observe_core(self.app.coordination_access.events) observed = await self.app.preparation.run_thread( - self.app.coordination_access.observation.observed) + self.app.coordination_access.observation.observed + ) observed.show_sidebar(self) def on_unmount(self) -> None: @@ -652,11 +690,15 @@ def bind_observation(self, observed: Observing) -> None: def channels_changed(self, changes: Changes[str]) -> None: self.present( change=ChangedChannels(frozenset((*changes.added, *changes.changed))), - reorder=bool(changes.added or changes.removed or changes.order is not None)) + reorder=bool(changes.added or changes.removed or changes.order is not None), + ) def threads_changed(self, changes: Changes[str]) -> None: - self.present(change=ChangedThreads(frozenset( - (*changes.added, *changes.removed, *changes.changed)))) + self.present( + change=ChangedThreads( + frozenset((*changes.added, *changes.removed, *changes.changed)) + ) + ) def present(self, **changes) -> None: """Publish the next state before it applies pending model changes.""" @@ -676,7 +718,9 @@ async def thread_row_state_changed(self, event: CoreEventMessage) -> None: class SearchFollower: def on_mount(self) -> None: - self.watch(self.query_ancestor(SideBar), "search", self._apply_search, init=True) + self.watch( + self.query_ancestor(SideBar), "search", self._apply_search, init=True + ) def _apply_search(self, value: str) -> None: self.search = value.casefold() @@ -719,7 +763,10 @@ def reconcile_members(self): yield finally: try: - if self._member_reconciliation_depth == 1 and self._member_reconciliation is not None: + if ( + self._member_reconciliation_depth == 1 + and self._member_reconciliation is not None + ): self._member_reconciliation.finish() finally: self._member_reconciliation_depth -= 1 @@ -752,7 +799,9 @@ def selection_change(self): try: yield finally: - for identity in previous.symmetric_difference(self.selection_state.selected_targets): + for identity in previous.symmetric_difference( + self.selection_state.selected_targets + ): if (row := self.row_for(identity)) is not None: row.refresh_paint() @@ -779,12 +828,19 @@ def pointer_select(self, row, *, control=False, shift=False, menu=False) -> None identities = tuple(self.selection_for(item) for item in rows) with self.selection_change(): if shift and state.selected in identities: - start, end = sorted((identities.index(state.selected), identities.index(selected))) - span = identities[start:end + 1] - state.selected_targets = tuple(dict.fromkeys((*current, *span))) if control else span + start, end = sorted( + (identities.index(state.selected), identities.index(selected)) + ) + span = identities[start : end + 1] + state.selected_targets = ( + tuple(dict.fromkeys((*current, *span))) if control else span + ) elif control: - state.selected_targets = (tuple(item for item in current if item != selected) - if selected in current else (*current, selected)) + state.selected_targets = ( + tuple(item for item in current if item != selected) + if selected in current + else (*current, selected) + ) state.selected = selected else: state.selected = selected @@ -793,38 +849,56 @@ def pointer_select(self, row, *, control=False, shift=False, menu=False) -> None def apply_selection(self) -> None: with self.selection_change(): state = self.selection_state - state.selected_targets = tuple(identity for identity in state.selected_targets - if self.row_for(identity) is not None) + state.selected_targets = tuple( + identity + for identity in state.selected_targets + if self.row_for(identity) is not None + ) @property def navigation_root(self): raise NotImplementedError def row_current(self, row) -> bool: - return self.app.workspace_sessions.visible_target(self.navigation_root).matches(row.target) + return self.app.workspace_sessions.visible_target(self.navigation_root).matches( + row.target + ) def rows_for_target(self, target): - return tuple(row for group in self.navigation_groups for row in target.rows_in(group)) + return tuple( + row for group in self.navigation_groups for row in target.rows_in(group) + ) @property def current_rows(self): target = self.app.workspace_sessions.visible_target(self.navigation_root) - return tuple(row for group in self.navigation_groups if group.accepts_navigation() - for row in target.rows_in(group) if row is group.row or group.members_visible) + return tuple( + row + for group in self.navigation_groups + if group.accepts_navigation() + for row in target.rows_in(group) + if row is group.row or group.members_visible + ) @handles(SessionSelected) def session_selected(self, event) -> None: if not self.rows: return workspace = self.app.workspace_sessions - previous = workspace.target_for_mode(event.event.previous_mode, self.navigation_root) + previous = workspace.target_for_mode( + event.event.previous_mode, self.navigation_root + ) current = workspace.visible_target(self.navigation_root) - for row in set(self.rows_for_target(previous)).symmetric_difference(self.rows_for_target(current)): + for row in set(self.rows_for_target(previous)).symmetric_difference( + self.rows_for_target(current) + ): row.paint_current() @property def rows(self) -> list[CommsRow]: - return [row for group in self.navigation_groups for row in group.navigation_rows] + return [ + row for group in self.navigation_groups for row in group.navigation_rows + ] def action_cursor_up(self) -> None: self.move_cursor(-1) @@ -836,7 +910,11 @@ def move_cursor(self, direction: int) -> None: rows = self.rows if rows: focused = self.app.focused - index = rows.index(focused) if focused in rows else -1 if direction > 0 else len(rows) + index = ( + rows.index(focused) + if focused in rows + else -1 if direction > 0 else len(rows) + ) row = rows[max(0, min(len(rows) - 1, index + direction))] self.focus_row(row) diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 3ddd636c0..621175f98 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -27,10 +27,21 @@ from agent_comms.ui_model.message_category import MessageCategory from toad.widgets.session_sort import SortControl, ThreadSortForm from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarVisibilityObserver -from toad.widgets.sidebar_tree import SidebarChange, SidebarGroup, SidebarModelView, SearchFollower, TargetTree -from agent_comms.relationships import RelationshipEntry, RelationshipGroup, ThreadCommsSnapshot +from toad.widgets.sidebar_tree import ( + SidebarChange, + SidebarGroup, + SidebarModelView, + SearchFollower, + TargetTree, +) +from agent_comms.relationships import ( + RelationshipEntry, + RelationshipGroup, + ThreadCommsSnapshot, +) from toad.widgets.thread_comms_source import RelationshipSource + def entry_target(entry: RelationshipEntry) -> NavigationTarget: person = entry.person if person is None: @@ -63,13 +74,21 @@ def order_writer(self): async def write(order: type[ThreadSort]): service = await tree.relationship_source(identity) - if generation != tree._generation or identity != tree.thread_view.thread_identity: + if ( + generation != tree._generation + or identity != tree.thread_view.thread_identity + ): return try: for group in groups: - await self.app.preparation.run_thread(service.set_order, identity.thread_name(), type(group), order) + await self.app.preparation.run_thread( + service.set_order, identity.thread_name(), type(group), order + ) finally: - if generation == tree._generation and identity == tree.thread_view.thread_identity: + if ( + generation == tree._generation + and identity == tree.thread_view.thread_identity + ): tree.refresh_relationships(force=True) return write @@ -85,17 +104,31 @@ def present(self, entry, model) -> None: self.available, self.target = entry.available, entry_target(entry) if not entry.available: self.retire_thread_row() - self.update(Content(f"? {entry.target}\n Unavailable · Ctrl+C copies name"), layout=False) + self.update( + Content(f"? {entry.target}\n Unavailable · Ctrl+C copies name"), + layout=False, + ) elif entry.person is not None: - if model is not None and model.incarnation == entry.person.thread.incarnation: + if ( + model is not None + and model.incarnation == entry.person.thread.incarnation + ): self.workers.cancel_group(self, "relationship-row") self._show_person(entry, model) elif previous != entry: - self.run_worker(self._present_person(entry), group="relationship-row", exclusive=True) + self.run_worker( + self._present_person(entry), + group="relationship-row", + exclusive=True, + ) elif previous != entry: self.set_label(entry.target) if entry.person is None: - self.tooltip = Content(f"{entry.target}\n{entry.detail}") if entry.detail else entry.target + self.tooltip = ( + Content(f"{entry.target}\n{entry.detail}") + if entry.detail + else entry.target + ) def _show_person(self, entry, model): self.show(model) @@ -103,7 +136,9 @@ def _show_person(self, entry, model): self.tooltip = Content(f"{entry.target}\n{entry.detail}") async def _present_person(self, entry): - model = await self.app.preparation.run_thread(ThreadRowModel.of, entry.person, UnobservedUnread()) + model = await self.app.preparation.run_thread( + ThreadRowModel.of, entry.person, UnobservedUnread() + ) if self.entry == entry: self._show_person(entry, model) @@ -114,15 +149,19 @@ def action_open_selected(self) -> None: tree = self.query_ancestor(ThreadCommsSidebar) tree.remember_row(self) if not self.available: - self.notify("This historical thread is unavailable. Ctrl+C copies its name.", - title="Comms") + self.notify( + "This historical thread is unavailable. Ctrl+C copies its name.", + title="Comms", + ) return tree.request_navigation(self) def on_click(self, event) -> None: if event.button == 3: event.stop() - self.query_ancestor(ThreadCommsSidebar).show_row_menu(self, event.screen_offset) + self.query_ancestor(ThreadCommsSidebar).show_row_menu( + self, event.screen_offset + ) class RelationshipRows(SidebarGroup[RelationshipRow]): @@ -132,8 +171,11 @@ class RelationshipRows(SidebarGroup[RelationshipRow]): def __init__(self, model: RelationshipGroup, expanded: bool): self.model = model - super().__init__(Static(model.title), expanded=expanded, - id=f"relationships-{type(model).declared_name}") + super().__init__( + Static(model.title), + expanded=expanded, + id=f"relationships-{type(model).declared_name}", + ) def members_visibility_changed(self, previous: bool, expanded: bool) -> None: state = self.query_ancestor(ThreadCommsSidebar).view_state @@ -147,18 +189,24 @@ def present(self, model: RelationshipGroup) -> None: previous, self.model = self.model, model if tuple(previous.entries.items()) != tuple(model.entries.items()): changed = frozenset( - t for t in model.entries + t + for t in model.entries if t not in previous.entries or previous.entries[t] != model.entries[t] ) self.sync_members(changed=changed) def accepts_members(self) -> bool: - return (super().accepts_members() - and self.query_ancestor(ThreadCommsSidebar).accepts_observation()) + return ( + super().accepts_members() + and self.query_ancestor(ThreadCommsSidebar).accepts_observation() + ) def keys_for_threads(self, names: frozenset[str]) -> frozenset[str]: - return frozenset(entry.target for entry in self.model.entries.values() - if entry.person is not None and entry.person.thread.name in names) + return frozenset( + entry.target + for entry in self.model.entries.values() + if entry.person is not None and entry.person.thread.name in names + ) def new_row(self, key: str): entry = self.model.entries[key] @@ -167,7 +215,10 @@ def new_row(self, key: str): def show_member(self, key: str, row: RelationshipRow) -> None: entry = self.model.entries[key] threads = self.query_ancestor(ThreadCommsSidebar).model_state.shown.threads.rows - row.present(entry, threads.get(entry.person.thread.name) if entry.person is not None else None) + row.present( + entry, + threads.get(entry.person.thread.name) if entry.person is not None else None, + ) def sync_members(self, changed: frozenset[str]) -> None: """Reconcile member rows from this group's entries and the tree's derived thread rows.""" @@ -183,18 +234,31 @@ def sync_members(self, changed: frozenset[str]) -> None: tree.apply_selection() -class ThreadCommsSidebar(ShowsThreadIdentity, SidebarModelView, SidebarVisibilityObserver, SearchFollower, TargetTree): +class ThreadCommsSidebar( + ShowsThreadIdentity, + SidebarModelView, + SidebarVisibilityObserver, + SearchFollower, + TargetTree, +): DEFAULT_CSS = """ ThreadCommsSidebar { height: auto; } ThreadCommsSidebar .relationship-context { height: auto; text-wrap: nowrap; text-overflow: clip; color: $text-muted; } ThreadCommsSidebar .relationship-empty { height: 1; color: $text-muted; } ThreadCommsSidebar > Checkbox { height: 1; border: none; padding: 0; margin: 0; background: transparent; } """ - BINDINGS = [("up", "cursor_up", "Previous"), ("down", "cursor_down", "Next"), - ("enter", "open_selected", "Open")] - - def __init__(self, *, source: RelationshipSource | None = None, - states: dict[SessionIdentity, RelationshipTreeState] | None = None): + BINDINGS = [ + ("up", "cursor_up", "Previous"), + ("down", "cursor_down", "Next"), + ("enter", "open_selected", "Open"), + ] + + def __init__( + self, + *, + source: RelationshipSource | None = None, + states: dict[SessionIdentity, RelationshipTreeState] | None = None, + ): super().__init__() self._generation = 0 self._refresh_task = None @@ -210,14 +274,19 @@ def __init__(self, *, source: RelationshipSource | None = None, @property def view_state(self): - return self._states.setdefault(self.thread_view.thread_identity, RelationshipTreeState()) + return self._states.setdefault( + self.thread_view.thread_identity, RelationshipTreeState() + ) async def relationship_source(self, identity: ThreadIdentity) -> RelationshipSource: if self._source is not None: return self._source access = self.app.coordination_access return await self.app.preparation.run_thread( - lambda: access.require(RouteSelection.capture(identity.root_path())).service.relationships) + lambda: access.require( + RouteSelection.capture(identity.root_path()) + ).service.relationships + ) def use_source(self, source: RelationshipSource) -> None: self._source = source @@ -225,11 +294,19 @@ def use_source(self, source: RelationshipSource) -> None: self.refresh_relationships(force=True) def compose(self): - yield Static(self.thread_view.thread_identity.display_thread(), classes="relationship-context", markup=False) + yield Static( + self.thread_view.thread_identity.display_thread(), + classes="relationship-context", + markup=False, + ) self._filter_controls.clear() for category in MessageCategory.members_with(MessageCategory): - checkbox = Checkbox(category.label, id=f"filter-{category.declared_name}", - classes="message-filter", compact=True) + checkbox = Checkbox( + category.label, + id=f"filter-{category.declared_name}", + classes="message-filter", + compact=True, + ) self._filter_controls[checkbox] = category yield checkbox @@ -258,7 +335,8 @@ def identity_changed(self, identity: SessionIdentity) -> None: selected = self.view_state.selected self.selected = selected[1] if selected is not None else None self.query_one(".relationship-context", Static).update( - Content(f"For {identity.display_thread()}"), layout=False) + Content(f"For {identity.display_thread()}"), layout=False + ) self.refresh_relationships(force=True) def _reset_relationships(self) -> None: @@ -280,8 +358,11 @@ def sync(self, *, change: SidebarChange, reorder: bool = False) -> None: change.apply_relationships(self.navigation_groups) def _presentation_visible(self) -> bool: - return (self.is_attached and self.screen.is_current - and self.query_ancestor(SideBar).presentation_visible) + return ( + self.is_attached + and self.screen.is_current + and self.query_ancestor(SideBar).presentation_visible + ) @handles(core_events.OpenTabsChanged, core_events.SessionSelected) async def _session_changed(self, event: CoreEventMessage) -> None: @@ -309,7 +390,9 @@ def _sync_filter_control(self): self.watch(view, "visible_categories", self._sync_filter_control) with self.prevent(Checkbox.Changed): for checkbox in checkboxes: - checkbox.value = self._filter_controls[checkbox] in view.visible_categories + checkbox.value = ( + self._filter_controls[checkbox] in view.visible_categories + ) @on(Checkbox.Changed, ".message-filter") def filter_changed(self, event): @@ -323,9 +406,12 @@ def filter_changed(self, event): ) def _visible(self): - return (self.is_attached and self.screen.is_current - and self in self.screen._compositor.visible_widgets - and not self.query_ancestor(SideBarCollapsible).collapsed) + return ( + self.is_attached + and self.screen.is_current + and self in self.screen._compositor.visible_widgets + and not self.query_ancestor(SideBarCollapsible).collapsed + ) def refresh_relationships(self, *, force=False): if force: @@ -334,12 +420,17 @@ def refresh_relationships(self, *, force=False): return if self._refresh_task is not None and not self._refresh_task.is_finished: return - self._refresh_task = self.run_worker(self._refresh(self._generation), group="relationships") + self._refresh_task = self.run_worker( + self._refresh(self._generation), group="relationships" + ) async def _refresh(self, generation): await self.thread_view.thread_identity.inspect_owner( lambda identity: self._refresh_owner(identity, generation), - lambda text: self.query_one(".relationship-context", Static).update(Content(text), layout=False)) + lambda text: self.query_one(".relationship-context", Static).update( + Content(text), layout=False + ), + ) async def _refresh_owner(self, identity: ThreadIdentity, generation): try: @@ -366,10 +457,20 @@ async def _refresh_owner(self, identity: ThreadIdentity, generation): absent = self.groups.keys() - models.keys() self._snapshot, self._revision = snapshot, revision context = self.query_one(".relationship-context", Static) - context.update(Content.assemble((f"For @{snapshot.owner}", "bold"), - " · recent window" if snapshot.history_limited else ""), layout=False) - context.tooltip = snapshot.incoming_basis + f"\nLatest {snapshot.history_messages} wire messages" - control = self.query_ancestor(SideBarCollapsible).query_one_optional(RelationshipSort) + context.update( + Content.assemble( + (f"For @{snapshot.owner}", "bold"), + " · recent window" if snapshot.history_limited else "", + ), + layout=False, + ) + context.tooltip = ( + snapshot.incoming_basis + + f"\nLatest {snapshot.history_messages} wire messages" + ) + control = self.query_ancestor(SideBarCollapsible).query_one_optional( + RelationshipSort + ) if control is not None: control.update_groups(snapshot.sortable_groups) with self.reconcile_members(): @@ -378,7 +479,8 @@ async def _refresh_owner(self, identity: ThreadIdentity, generation): if group is None: # A new group reconciles once composed (on_mount). group = self.groups[identity] = RelationshipRows( - model, self.view_state.expanded.get(identity, True)) + model, self.view_state.expanded.get(identity, True) + ) await self.mount(group) if generation != self._generation or not self.is_attached: return @@ -392,7 +494,15 @@ async def _refresh_owner(self, identity: ThreadIdentity, generation): @property def member_keys(self): - return (entry.target for model in self._snapshot.groups for entry in model.entries.values()) if self._snapshot is not None else () + return ( + ( + entry.target + for model in self._snapshot.groups + for entry in model.entries.values() + ) + if self._snapshot is not None + else () + ) @property def navigation_groups(self): @@ -407,7 +517,13 @@ def selection_for(self, row): def row_for(self, selection): group = self.groups.get(selection[0]) - row = group.rows.get(selection[1][1]) if group is not None and group.members_visible and group.accepts_navigation() else None + row = ( + group.rows.get(selection[1][1]) + if group is not None + and group.members_visible + and group.accepts_navigation() + else None + ) return row if row is not None and row.entry.identity == selection[1] else None @property @@ -416,9 +532,15 @@ def navigation_root(self): def open_target(self, target: NavigationTarget): identity = self.thread_view.thread_identity - if identity.root_path() != self.app.coordination_access.observation.observed().service.root.resolve(): - self.notify("This view uses a different wire; open its matching connection to navigate.", - title="Comms", severity="warning") + if ( + identity.root_path() + != self.app.coordination_access.observation.observed().service.root.resolve() + ): + self.notify( + "This view uses a different wire; open its matching connection to navigate.", + title="Comms", + severity="warning", + ) return self.publish_core(SelectTarget(target)) @@ -436,12 +558,18 @@ async def _navigate_current(self, group_key, expected, generation): identity = self.thread_view.thread_identity service = await self.relationship_source(identity) snapshot = await asyncio.to_thread(service.snapshot, identity.thread_name()) - if (generation != self._generation or not self.is_attached - or not self.screen.is_current): + if ( + generation != self._generation + or not self.is_attached + or not self.screen.is_current + ): return current = snapshot.current_entry(group_key, expected) if current is None: - self.notify("This relationship changed; refresh the list before opening it.", title="Comms") + self.notify( + "This relationship changed; refresh the list before opening it.", + title="Comms", + ) self.refresh_relationships(force=True) return self.open_target(entry_target(current)) @@ -459,6 +587,10 @@ def open_selected(): items = [("open", "Open")] if row.available else [] items.append(("copy", "Copy name")) - show_target_menu(self.screen, offset, target, items, - {"open": open_selected, - "copy": lambda: self.app.copy_to_clipboard(target)}) + show_target_menu( + self.screen, + offset, + target, + items, + {"open": open_selected, "copy": lambda: self.app.copy_to_clipboard(target)}, + ) diff --git a/src/toad/widgets/thread_identity.py b/src/toad/widgets/thread_identity.py index b6d76117b..79da3e028 100644 --- a/src/toad/widgets/thread_identity.py +++ b/src/toad/widgets/thread_identity.py @@ -1,4 +1,5 @@ """Panels read identity from their logical session, not the native workspace.""" + from agent_comms.mro_dispatch import handles from textual.widget import Widget diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 85189ee11..4522ce94d 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -34,32 +34,69 @@ from textual.widgets import Static from toad.transcript_filter import FilterState -from toad.transcript_state import TranscriptState, LiveTranscript, ParkedSourceTranscript, LatestViewportRequest -from toad.transcript_source_preparation import TranscriptSourcePreparation, _DrawingStopped +from toad.transcript_state import ( + TranscriptState, + LiveTranscript, + ParkedSourceTranscript, + LatestViewportRequest, +) +from toad.transcript_source_preparation import ( + TranscriptSourcePreparation, + _DrawingStopped, +) from toad.transcript_preparation import ( - RangeCategoryProjection, PreparedPageSource, PreparedTranscriptPage, PageFragmentRange, - ProjectedTranscriptSource, SavedHistory, ClosedHistory, UnreadTranscriptPage, + RangeCategoryProjection, + PreparedPageSource, + PreparedTranscriptPage, + PageFragmentRange, + ProjectedTranscriptSource, + SavedHistory, + ClosedHistory, + UnreadTranscriptPage, ) from toad.response_delivery import ResponseDelivery from toad.message_rows import MessageClock from toad.widgets.selection import HoverSelection from rich.style import Style as RichStyle from ui_theme.effects import NEW_TEXT -from toad.widgets.presentation_window import PresentationBudget, PageEnd, OLDER_END, NEWER_END +from toad.widgets.presentation_window import ( + PresentationBudget, + PageEnd, + OLDER_END, + NEWER_END, +) from toad.line_blocks import ToggleDetails from toad.message_rows import MessageRow from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications -from toad.widgets.linked_rows import HorizontalRows, OpensMessageNames, ReferenceRendering, selection_painted +from toad.widgets.linked_rows import ( + HorizontalRows, + OpensMessageNames, + ReferenceRendering, + selection_painted, +) from toad.widgets.transcript_lines import ( - Lines, BlockLines, BlockSpan, LinePreparation, PreparedLines, TranscriptLinesBatchRenderTask, TranscriptLinesRenderTask, + Lines, + BlockLines, + BlockSpan, + LinePreparation, + PreparedLines, + TranscriptLinesBatchRenderTask, + TranscriptLinesRenderTask, ) from toad.widgets.committed_presentation import CommittedHistory -from toad.widgets.history_anchor import HeldReaderDestination, HistoryWindow, LatestPosition +from toad.widgets.history_anchor import ( + HeldReaderDestination, + HistoryWindow, + LatestPosition, +) from agent_comms.ui_model.message_category import ( - all_categories, MessageCategory, + all_categories, + MessageCategory, ) from toad.widgets.transcript_fragments import ( - Shown, TranscriptFragment, transcript_fragments, + Shown, + TranscriptFragment, + transcript_fragments, ) if TYPE_CHECKING: @@ -85,6 +122,7 @@ def on_click(self, event) -> None: event.stop() self.action_requested() + class JumpToLatest(HoverSelection, HistoryEdge): highlighted_text_style = RichStyle(bold=True) BINDINGS = [("enter,space", "requested", "Jump to latest")] @@ -93,6 +131,7 @@ class JumpToLatest(HoverSelection, HistoryEdge): class Requested(Message): pass + @dataclass(frozen=True) class FragmentSpan(BlockSpan): """A block span placed in its page, carrying the message it belongs to.""" @@ -100,9 +139,16 @@ class FragmentSpan(BlockSpan): fragment: TranscriptFragment = field(kw_only=True) @classmethod - def in_page(cls, fragment: TranscriptFragment, first: int, span: BlockSpan) -> FragmentSpan: - return cls(index=span.index, first=first + span.first, height=span.height, - drawn=span.drawn, fragment=fragment) + def in_page( + cls, fragment: TranscriptFragment, first: int, span: BlockSpan + ) -> FragmentSpan: + return cls( + index=span.index, + first=first + span.first, + height=span.height, + drawn=span.drawn, + fragment=fragment, + ) class TranscriptPageView(ReferenceRendering, HorizontalRows, Widget): @@ -113,11 +159,17 @@ class TranscriptPageView(ReferenceRendering, HorizontalRows, Widget): The history marks arriving fragments; only their rows rise out of the background. """ - DEFAULT_CSS = "TranscriptPageView { height: auto; overflow-x: auto; overflow-y: hidden; }" + DEFAULT_CSS = ( + "TranscriptPageView { height: auto; overflow-x: auto; overflow-y: hidden; }" + ) RENDER_BATCH = 8 - def __init__(self, prepared: PreparedTranscriptPage, *, - batch_size: int = PresentationBudget.batch_items): + def __init__( + self, + prepared: PreparedTranscriptPage, + *, + batch_size: int = PresentationBudget.batch_items, + ): self.arrivals: set[int] = set() self.prepared = prepared self.visible_categories = all_categories() @@ -148,7 +200,9 @@ def line_sets(self) -> tuple[Lines, ...]: def toggle_details(self, link: ToggleDetails) -> None: """Show or hide the details of the fragment whose line was clicked.""" history = self.history - history.run_worker(history.toggle_details(self.fragment_under_mouse()), group="message-details") + history.run_worker( + history.toggle_details(self.fragment_under_mouse()), group="message-details" + ) @property def line_width(self) -> int: @@ -223,7 +277,10 @@ def _measure(self, width: int) -> None: self._layout, self._starts, self._height = layout, starts, y for first, height in repaint: self.refresh(Region(0, first, width, height)) - self._fragment_slots = {id(fragment): index for index, (fragment, _first, _lines) in enumerate(layout)} + self._fragment_slots = { + id(fragment): index + for index, (fragment, _first, _lines) in enumerate(layout) + } if self.is_mounted: self.history.request_preparation() @@ -231,13 +288,20 @@ def prepare_viewport(self, window) -> None: """Prepare only the measured rows the window currently exposes.""" theme = self.app.colours.line_theme visible = tuple(self.visible_rows(window)) - if any(lines.width != self._width or not fragment.retains_lines(lines, theme) - for fragment, _rows, lines in visible): + if any( + lines.width != self._width or not fragment.retains_lines(lines, theme) + for fragment, _rows, lines in visible + ): self.range_changed() return - if (visible and self._pending - and (self._render_task is None or self._render_task.done())): - self._render_task = asyncio.create_task(self._render_pending(), eager_start=False) + if ( + visible + and self._pending + and (self._render_task is None or self._render_task.done()) + ): + self._render_task = asyncio.create_task( + self._render_pending(), eager_start=False + ) @height_dependency(INDEPENDENT_HEIGHT) def get_content_height(self, container, viewport, width: int) -> int: @@ -265,7 +329,9 @@ def render_line(self, y: int, paint: PaintState) -> Strip: strip = history.effect_phase(NEW_TEXT).paint(history, NEW_TEXT, strip) return selection_painted(self, self.on_background(strip, paint, y), y) - def fragment_row(self, position: int | str, part: int, block: int) -> FragmentSpan | None: + def fragment_row( + self, position: int | str, part: int, block: int + ) -> FragmentSpan | None: """A source child's first measured row, height and whether the page draws it. A hidden fragment lands on the next drawn fragment, otherwise the @@ -273,8 +339,14 @@ def fragment_row(self, position: int | str, part: int, block: int) -> FragmentSp source position; an empty range is filled by source preparation. """ loaded = self.prepared.fragments_in_range - at = next((index for index, candidate in enumerate(loaded) - if candidate.matches_position(position, part)), None) + at = next( + ( + index + for index, candidate in enumerate(loaded) + if candidate.matches_position(position, part) + ), + None, + ) if at is None: return None for candidate in chain(loaded[at:], reversed(loaded[:at])): @@ -282,7 +354,9 @@ def fragment_row(self, position: int | str, part: int, block: int) -> FragmentSp _fragment, first, lines = self._layout[slot] same = candidate.matches_position(position, part) wanted = block if same else 0 - span = lines.block_span(lambda span: span.drawn and span.index >= wanted) + span = lines.block_span( + lambda span: span.drawn and span.index >= wanted + ) if span is not None: placed = FragmentSpan.in_page(candidate, first, span) return replace(placed, drawn=same and span.index == block) @@ -317,7 +391,11 @@ def fragment_under_mouse(self) -> TranscriptFragment | None: def tooltip(self) -> str | None: """The full time of the message under the pointer.""" fragment = self.fragment_under_mouse() - return None if fragment is None else MessageClock.recorded(fragment.timestamp).display()[1] + return ( + None + if fragment is None + else MessageClock.recorded(fragment.timestamp).display()[1] + ) def fragment_at(self, row: int) -> FragmentSpan | None: """The measured child drawn here, with its containing message.""" @@ -343,7 +421,9 @@ def visible_rows(self, window) -> Iterator[tuple[TranscriptFragment, range, Line if first >= bottom: break if first + lines.height > top: - yield fragment, range(max(0, top - first), min(lines.height, bottom - first)), lines + yield fragment, range( + max(0, top - first), min(lines.height, bottom - first) + ), lines def visible_fragments(self, window) -> tuple[TranscriptFragment, ...]: """The fragments this page draws inside the window's viewport.""" @@ -354,13 +434,18 @@ def _nearest_pending(self) -> list[TranscriptFragment]: window = self.query_ancestor(HistoryWindow) slots = self._fragment_slots - pending = [fragment for fragment in self.visible_fragments(window) - if id(fragment) in self._pending] + pending = [ + fragment + for fragment in self.visible_fragments(window) + if id(fragment) in self._pending + ] # Render only visible viewport rows, nearest the reader first. if (offset := window.placed_row(self)) is not None: reader = window.scroll_y - offset - pending.sort(key=lambda fragment: abs(self._layout[slots[id(fragment)]][1] - reader)) - return pending[:self.RENDER_BATCH] + pending.sort( + key=lambda fragment: abs(self._layout[slots[id(fragment)]][1] - reader) + ) + return pending[: self.RENDER_BATCH] async def _render_pending(self) -> None: while self._pending and self.is_attached: @@ -370,12 +455,23 @@ async def _render_pending(self) -> None: for fragment in batch: del self._pending[id(fragment)] theme = self.app.colours.line_theme - ready = await TranscriptLinesBatchRenderTask.render_for(self, - (LinePreparation(fragment.line_blocks, theme) for fragment in batch)) - await self._publish([(fragment, task, lines) - for fragment, (task, lines) in zip(batch, ready)]) + ready = await TranscriptLinesBatchRenderTask.render_for( + self, + (LinePreparation(fragment.line_blocks, theme) for fragment in batch), + ) + await self._publish( + [ + (fragment, task, lines) + for fragment, (task, lines) in zip(batch, ready) + ] + ) - async def _publish(self, ready: list[tuple[TranscriptFragment, TranscriptLinesRenderTask, LinePreparation]]) -> None: + async def _publish( + self, + ready: list[ + tuple[TranscriptFragment, TranscriptLinesRenderTask, LinePreparation] + ], + ) -> None: from toad.widgets.history_anchor import HistoryWindow # Native frame completion owns when new row geometry may replace the @@ -385,7 +481,9 @@ async def _publish(self, ready: list[tuple[TranscriptFragment, TranscriptLinesRe # Holding rows does not change the page's measured layout. Retain every # result before opening a history change: keeping the reader can # interrupt an unfinished layout, even without accepted rows. - if not any([task.hold(self, fragment, lines) for fragment, task, lines in ready]): + if not any( + [task.hold(self, fragment, lines) for fragment, task, lines in ready] + ): return window = self.query_ancestor(HistoryWindow) async with window.preserve_history(root=self): @@ -394,8 +492,13 @@ async def _publish(self, ready: list[tuple[TranscriptFragment, TranscriptLinesRe @classmethod @asynccontextmanager async def acquire( - cls, owner: LineHistory, prepared: PreparedTranscriptPage, *, - end: PageEnd, pages: deque[TranscriptPageView], current: Callable[[], bool], + cls, + owner: LineHistory, + prepared: PreparedTranscriptPage, + *, + end: PageEnd, + pages: deque[TranscriptPageView], + current: Callable[[], bool], ) -> AsyncIterator[TranscriptPageView]: """Acquire the original prepared range, without selecting it a second time.""" view = cls(prepared) @@ -418,7 +521,9 @@ def set_categories(self, selected: frozenset[type[MessageCategory]]) -> None: self.range_changed() async def select_range( - self, selected: slice, current: Callable[[], bool], + self, + selected: slice, + current: Callable[[], bool], ) -> bool: if not current(): return False @@ -429,7 +534,14 @@ async def select_range( async with window.preserve_history(root=self): return await self.prepared.replace_range(self, selected, current) -class LineHistory(SourceNotifications, OpensMessageNames, TranscriptSourcePreparation, ConversationEntry, VerticalGroup): + +class LineHistory( + SourceNotifications, + OpensMessageNames, + TranscriptSourcePreparation, + ConversationEntry, + VerticalGroup, +): """History drawn as lines, a bounded range of pages at a time. It owns paging in both directions, lookahead, trimming, the jump to the newest page and the reader's position. @@ -439,6 +551,7 @@ class LineHistory(SourceNotifications, OpensMessageNames, TranscriptSourcePrepar CACHE_SUBTREE_GEOMETRY = True DEFAULT_BUDGET = PresentationBudget(max_items=240) + @classmethod def empty_source(cls, view, through): return ClosedHistory(through, view.app.preparation) @@ -446,8 +559,13 @@ def empty_source(cls, view, through): @classmethod def empty(cls, *, view, notification_view): prepared = cls.empty_page() - return cls(prepared, cls.empty_source(view, prepared.page.after), - view=view, notification_view=notification_view, source_state=cls.initial_state(view)) + return cls( + prepared, + cls.empty_source(view, prepared.page.after), + view=view, + notification_view=notification_view, + source_state=cls.initial_state(view), + ) def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> None: self.projection_changed() @@ -459,16 +577,30 @@ def starts_agent_activity(self, boundary) -> bool: def block_cursor(self): return FragmentBlockCursor(self) - def __init__(self, prepared: PreparedTranscriptPage, page_source: PreparedPageSource, - *, view, notification_view: HistoryNotifications, budget: PresentationBudget = DEFAULT_BUDGET, - source_state: TranscriptState | None = None): + def __init__( + self, + prepared: PreparedTranscriptPage, + page_source: PreparedPageSource, + *, + view, + notification_view: HistoryNotifications, + budget: PresentationBudget = DEFAULT_BUDGET, + source_state: TranscriptState | None = None, + ): self.view = view self.notification_view = notification_view - super().__init__(source_state=source_state or LiveTranscript(), page_source=page_source) + super().__init__( + source_state=source_state or LiveTranscript(), page_source=page_source + ) self.budget = budget - self.pages = deque([TranscriptPageView( - prepared, batch_size=self.budget.batch_items, - )]) + self.pages = deque( + [ + TranscriptPageView( + prepared, + batch_size=self.budget.batch_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" self.newer = JumpToLatest("↓ Jump to latest") @@ -494,23 +626,33 @@ def resume_source(self) -> None: super().resume_source() self.filter.overlay.resume_source() - async def prepare_fragments(self, fragments: tuple[TranscriptFragment, ...], - current: Callable[[], bool], *, selected=None, - target: LineHistory | Contents | None = None) -> None: + async def prepare_fragments( + self, + fragments: tuple[TranscriptFragment, ...], + current: Callable[[], bool], + *, + selected=None, + target: LineHistory | Contents | None = None, + ) -> None: """Render against the native destination, including before history is mounted.""" target = self if target is None else target selected = self.selected_categories if selected is None else selected width, theme = target.line_width, self.app.colours.line_theme if width <= 0: return - pending = (fragment for fragment in fragments - if fragment.shown_in(selected) and fragment.lines_for(width, theme) is None) + pending = ( + fragment + for fragment in fragments + if fragment.shown_in(selected) and fragment.lines_for(width, theme) is None + ) while current(): batch = tuple(islice(pending, self.budget.batch_items)) if not batch: return - ready = await TranscriptLinesBatchRenderTask.render_for(target, - (LinePreparation(fragment.line_blocks, theme) for fragment in batch)) + ready = await TranscriptLinesBatchRenderTask.render_for( + target, + (LinePreparation(fragment.line_blocks, theme) for fragment in batch), + ) if not current(): return for fragment, (task, lines) in zip(batch, ready): @@ -521,9 +663,15 @@ def line_width(self) -> int: """Native content width used by preparation and page measurement.""" return max(0, self.size.width - self.scrollbar_gutter.width) - async def prepare_rows(self, current: Callable[[], bool], *, window: HistoryWindow, - target: Contents, selected: frozenset[type[MessageCategory]], - rows_needed: int) -> None: + async def prepare_rows( + self, + current: Callable[[], bool], + *, + window: HistoryWindow, + target: Contents, + selected: frozenset[type[MessageCategory]], + rows_needed: int, + ) -> None: """Prepare the destination before its first native layout can expose raw rows.""" window.prepare_history(self) if target.line_width <= 0: @@ -531,58 +679,105 @@ async def prepare_rows(self, current: Callable[[], bool], *, window: HistoryWind _, limit = window.latest_limits(self.budget) position = window.reader_position rows_needed, _ = await position.prepare( - self, current, target, selected, rows_needed, limit, + self, + current, + target, + selected, + rows_needed, + limit, ) await self.filter.prepare_rows( - current, window=window, target=target, rows_needed=max(0, rows_needed), + current, + window=window, + target=target, + rows_needed=max(0, rows_needed), ) @property def fragments(self) -> tuple[TranscriptFragment, ...]: - return tuple(fragment for page in self.pages for fragment in page.prepared.fragments) + return tuple( + fragment for page in self.pages for fragment in page.prepared.fragments + ) @property def fragments_in_range(self) -> tuple[TranscriptFragment, ...]: - return tuple(fragment for page in self.pages for fragment in page.prepared.fragments_in_range) + return tuple( + fragment + for page in self.pages + for fragment in page.prepared.fragments_in_range + ) def read_fragments(self) -> tuple[TranscriptFragment, ...]: """Fragments whose own text has a row in the viewport.""" if not self.is_attached or not self.can_draw_on_screen: return () return tuple( - fragment for page in self.visible_pages() for fragment, rows, lines in page.visible_rows(self.window) - if (text := fragment.text_rows(lines)) and rows.start < text.stop and text.start < rows.stop) + fragment + for page in self.visible_pages() + for fragment, rows, lines in page.visible_rows(self.window) + if (text := fragment.text_rows(lines)) + and rows.start < text.stop + and text.start < rows.stop + ) def visible_pages(self) -> list[TranscriptPageView]: """This history's laid-out pages, in row order, without visiting retained pages.""" visible = self.screen._compositor.visible_widgets - return sorted((page for page in visible - if isinstance(page, TranscriptPageView) and page.parent is self), - key=lambda page: visible[page][0].y) + return sorted( + ( + page + for page in visible + if isinstance(page, TranscriptPageView) and page.parent is self + ), + key=lambda page: visible[page][0].y, + ) def visible_fragments(self) -> tuple[TranscriptFragment, ...]: - return tuple(fragment for page in self.visible_pages() for fragment in page.visible_fragments(self.window)) + return tuple( + fragment + for page in self.visible_pages() + for fragment in page.visible_fragments(self.window) + ) # What a fragment shows async def _show(self, changes: list[tuple[TranscriptFragment, Shown]]) -> None: """Change what fragments draw: render the new lines first, then swap them in one frame.""" - changes = [(fragment, shown) for fragment, shown in changes if shown != fragment.shown] + changes = [ + (fragment, shown) for fragment, shown in changes if shown != fragment.shown + ] if not changes: return changed = {id(fragment) for fragment, _shown in changes} width, theme = self.line_width, self.app.colours.line_theme in_range = {id(fragment) for fragment in self.fragments_in_range} - drawn = [(fragment, shown) for fragment, shown in changes if id(fragment) in in_range and width > 0] - rendered = await TranscriptLinesBatchRenderTask.render_for(self, - (LinePreparation(fragment.blocks(shown), theme) for fragment, shown in drawn)) + drawn = [ + (fragment, shown) + for fragment, shown in changes + if id(fragment) in in_range and width > 0 + ] + rendered = await TranscriptLinesBatchRenderTask.render_for( + self, + ( + LinePreparation(fragment.blocks(shown), theme) + for fragment, shown in drawn + ), + ) if not self.state.can_draw: return - ready = {id(fragment): result for (fragment, _shown), result in zip(drawn, rendered)} + ready = { + id(fragment): result for (fragment, _shown), result in zip(drawn, rendered) + } async with self.window.preserve_history(root=self): for fragment, shown in changes: - TranscriptLinesRenderTask.show(self, fragment, shown, ready.get(id(fragment))) - for page in [page for page in self.pages if not changed.isdisjoint(map(id, page.prepared.fragments_in_range))]: + TranscriptLinesRenderTask.show( + self, fragment, shown, ready.get(id(fragment)) + ) + for page in [ + page + for page in self.pages + if not changed.isdisjoint(map(id, page.prepared.fragments_in_range)) + ]: page.range_changed() # A page holding a changed fragment measures again. self.window.check_follow() @@ -606,21 +801,31 @@ def hold_fragment_top(self, fragment: TranscriptFragment) -> None: if placed is None: continue if placed.drawn: - self.window.reader_position = FragmentPosition(position, 0, placed.height, part=part, block=0) + self.window.reader_position = FragmentPosition( + position, 0, placed.height, part=part, block=0 + ) return def can_expand(self) -> bool | None: fragment = self.block_cursor.current - return None if fragment is None or not fragment.has_details else not fragment.shown.details_open + return ( + None + if fragment is None or not fragment.has_details + else not fragment.shown.details_open + ) def is_block_expanded(self) -> bool: return self.can_expand() is False def expand_block(self) -> None: - self.run_worker(self.toggle_details(self.block_cursor.current), group="message-details") + self.run_worker( + self.toggle_details(self.block_cursor.current), group="message-details" + ) def collapse_block(self) -> None: - self.run_worker(self.toggle_details(self.block_cursor.current), group="message-details") + self.run_worker( + self.toggle_details(self.block_cursor.current), group="message-details" + ) # The message cursor @@ -632,14 +837,18 @@ def message_row(self) -> MessageRow: @property def drawn_fragments(self) -> tuple[TranscriptFragment, ...]: """Every fragment the pages draw rows for, oldest first.""" - return tuple(fragment for page in self.pages for fragment in page.drawn_fragments) + return tuple( + fragment for page in self.pages for fragment in page.drawn_fragments + ) def draws_fragment(self, fragment: TranscriptFragment) -> bool: return any(page.fragment_rows(fragment) is not None for page in self.pages) def fragment_under_mouse(self, widget: Widget) -> TranscriptFragment | None: """The fragment drawn under the pointer, when `widget` is one of this history's pages.""" - return next((page.fragment_under_mouse() for page in self.pages if page is widget), None) + return next( + (page.fragment_under_mouse() for page in self.pages if page is widget), None + ) def fragment_region(self, fragment: TranscriptFragment) -> Region | None: """Where the screen shows a fragment's rows; None while its page is off screen.""" @@ -684,15 +893,29 @@ def show_fragment(self, fragment: TranscriptFragment) -> None: @property def source_identity(self): - return self.page_source.reload_key(), self.page_source.through, self.selected_categories + return ( + self.page_source.reload_key(), + self.page_source.through, + self.selected_categories, + ) def paging_window(self): # What an edge check decides from: the saved extent, the pages' # loaded ranges and the filter. A check that finds a read running is # skipped, and the read asks again only when one of these changed # under it, so a filter chosen during a read must count. - return self.page_source.through, self.filter, tuple( - (page.prepared.page.before, page.prepared.page.after, page.prepared.capture_range()) for page in self.pages) + return ( + self.page_source.through, + self.filter, + tuple( + ( + page.prepared.page.before, + page.prepared.page.after, + page.prepared.capture_range(), + ) + for page in self.pages + ), + ) def capture_reader_ranges(self) -> tuple[PageFragmentRange, ...]: """Retain the original source ranges that a returning reader needs.""" @@ -717,13 +940,22 @@ async def restore_reader_ranges(self, ranges, current: Callable[[], bool]) -> bo continue if not current(): return False - if (page.prepared.start == page_range.start and page.prepared.stop == page_range.stop - and len(page.prepared.fragments_in_range) == len(page_range.members) - and all(fragment is member for fragment, member in - zip(page.prepared.fragments_in_range, page_range.members))): + if ( + page.prepared.start == page_range.start + and page.prepared.stop == page_range.stop + and len(page.prepared.fragments_in_range) == len(page_range.members) + and all( + fragment is member + for fragment, member in zip( + page.prepared.fragments_in_range, page_range.members + ) + ) + ): continue await self.prepare_fragments(page_range.members, current) - if not await page.select_range(slice(page_range.start, page_range.stop), current): + if not await page.select_range( + slice(page_range.start, page_range.stop), current + ): return False if not await self.filter.overlay.restore_reader_ranges(ranges, current): return False @@ -744,9 +976,17 @@ def invalidate_projection(self) -> None: def projected_source(self, selected) -> ProjectedTranscriptSource: page = self.pages[0] return ProjectedTranscriptSource( - page.prepared, self.page_source, RangeCategoryProjection( - selected, PageFragmentRange(ref(page.prepared), 0, page.prepared.start, - page.prepared.fragments[:page.prepared.start])), + page.prepared, + self.page_source, + RangeCategoryProjection( + selected, + PageFragmentRange( + ref(page.prepared), + 0, + page.prepared.start, + page.prepared.fragments[: page.prepared.start], + ), + ), ) @property @@ -782,7 +1022,9 @@ async def on_mount(self) -> None: def load_initial_page(self) -> None: self.request_latest(LatestPosition.hold(self.window)) - async def _page_added(self, page: TranscriptPage, fragments: tuple[TranscriptFragment, ...]) -> None: + async def _page_added( + self, page: TranscriptPage, fragments: tuple[TranscriptFragment, ...] + ) -> None: """A page joined what this history draws.""" def extend_source(self, through: TranscriptCursor) -> None: @@ -790,17 +1032,24 @@ def extend_source(self, through: TranscriptCursor) -> None: self._generation += 1 self.page_source.through = through edge = self.pages[-1] - edge.prepared.page = replace(edge.prepared.page, has_newer=edge.prepared.page.after != through) + edge.prepared.page = replace( + edge.prepared.page, has_newer=edge.prepared.page.after != through + ) self._update_edges() async def _finish_mount(self) -> None: - await self._page_added(self.pages[0].prepared.page, self.pages[0].prepared.fragments) + await self._page_added( + self.pages[0].prepared.page, self.pages[0].prepared.fragments + ) self._update_edges() self.observe_source() def _update_edges(self) -> None: shown = bool(self.selected_categories) - older, newer = shown and self.filter.older_visible, shown and NEWER_END.visible_more(self) + older, newer = ( + shown and self.filter.older_visible, + shown and NEWER_END.visible_more(self), + ) if (older, newer) != (self.older.display, self.newer.display): # The edge rows are above and below the reader's. self.window.keep_reader() @@ -811,7 +1060,14 @@ def conversation(self) -> Conversation | None: """The conversation whose live blocks these pages stand in for; None in a saved viewer.""" from toad.widgets.conversation import Conversation - return next((ancestor for ancestor in self.ancestors if isinstance(ancestor, Conversation)), None) + return next( + ( + ancestor + for ancestor in self.ancestors + if isinstance(ancestor, Conversation) + ), + None, + ) @property def selected_categories(self) -> frozenset[type[MessageCategory]]: @@ -819,13 +1075,18 @@ def selected_categories(self) -> frozenset[type[MessageCategory]]: # Root containers own selection; nested pagers already inherit their # outer message's category, not the synthetic Markdown fragment role. - return (self.parent.selected_categories - if self.is_attached and isinstance(self.parent, Contents) else all_categories()) + return ( + self.parent.selected_categories + if self.is_attached and isinstance(self.parent, Contents) + else all_categories() + ) @property def drawn_extent(self) -> tuple[int, int]: pages = [page for page in self.pages if page.line_count] - return sum(page.line_count for page in pages), sum(page.prepared.stop - page.prepared.start for page in pages) + return sum(page.line_count for page in pages), sum( + page.prepared.stop - page.prepared.start for page in pages + ) def _size_updated(self, size, virtual_size, container_size, layout=True) -> bool: # Edges are read from this history's laid-out box. A check made @@ -852,16 +1113,24 @@ def on_earlier_history(self, event: HistoryEdge.Requested) -> None: def drawn_tail_followed(self) -> bool: return not self.drawn_end_more and self.follows_source_tail - def repaint(self, fragments: tuple[TranscriptFragment, ...] = (), *, continuing=()) -> None: + def repaint( + self, fragments: tuple[TranscriptFragment, ...] = (), *, continuing=() + ) -> None: phase = self.effect_phase(NEW_TEXT) if fragments: phase.on_new_arrival(self, NEW_TEXT, self.pages) - positions = {fragment.arrival_position for fragment in (*fragments, *phase.keeps_arrivals(continuing))} + positions = { + fragment.arrival_position + for fragment in (*fragments, *phase.keeps_arrivals(continuing)) + } for page in self.pages: previous = bool(page.arrivals) page.arrivals.intersection_update(phase.keeps_arrivals(page.arrivals)) - page.arrivals.update(id(fragment) for fragment in page.prepared.fragments_in_range - if fragment.arrival_position in positions) + page.arrivals.update( + id(fragment) + for fragment in page.prepared.fragments_in_range + if fragment.arrival_position in positions + ) if previous or page.arrivals: page.refresh() @@ -869,12 +1138,24 @@ def on_hide(self) -> None: for page in self.pages: page.arrivals.clear() - async def show_newest(self, through, initial: PreparedTranscriptPage, *, - arrivals: tuple[TranscriptFragment, ...] = ()) -> None: + async def show_newest( + self, + through, + initial: PreparedTranscriptPage, + *, + arrivals: tuple[TranscriptFragment, ...] = (), + ) -> None: """Replace the drawn source, retaining the followed tail's active arrivals.""" - retained = (tuple(fragment for page in self.pages for fragment in page.prepared.fragments_in_range - if id(fragment) in page.arrivals) - if self.follows_source_tail else ()) + retained = ( + tuple( + fragment + for page in self.pages + for fragment in page.prepared.fragments_in_range + if id(fragment) in page.arrivals + ) + if self.follows_source_tail + else () + ) await self._clear(initial) self.extend_source(through) await self.jump_latest(LatestViewportRequest(LatestPosition.hold(self.window))) @@ -887,8 +1168,7 @@ async def _clear(self, initial: PreparedTranscriptPage) -> None: self._generation += 1 self.page_source.restart(initial.page.after) retired = tuple(self.pages) - view = TranscriptPageView(initial, - batch_size=self.budget.batch_items) + view = TranscriptPageView(initial, batch_size=self.budget.batch_items) view.visible_categories = self.selected_categories await self.mount(view, before=self.newer) self.pages = deque([view]) @@ -897,7 +1177,9 @@ async def _clear(self, initial: PreparedTranscriptPage) -> None: self._update_edges() async def _load_page( - self, end: PageEnd, prepared: PreparedTranscriptPage | None = None, + self, + end: PageEnd, + prepared: PreparedTranscriptPage | None = None, publication_current: Callable[[], bool] = lambda: True, ) -> bool: """Publish one readable range; report whether its edge still needs rows.""" @@ -910,7 +1192,11 @@ async def _load_page( def current() -> bool: if not snapshot.current(self) or not publication_current(): return False - return window.follows_tail if followed_end else window.lookahead.demand.accepts_result(end) + return ( + window.follows_tail + if followed_end + else window.lookahead.demand.accepts_result(end) + ) try: if not current(): @@ -919,8 +1205,11 @@ def current() -> bool: page_range = edge.prepared.capture_range() edge_cursor = edge.prepared.page.before, edge.prepared.page.after demand = window.lookahead.demand - missing = (window.lookahead.ahead_rows(window.outer_size.height) if followed_end - else window.lookahead.read_rows(self, end)) + missing = ( + window.lookahead.ahead_rows(window.outer_size.height) + if followed_end + else window.lookahead.read_rows(self, end) + ) local = end.can_extend(edge.prepared) staged = edge.prepared.for_view() if local else prepared if not local: @@ -932,11 +1221,19 @@ def current() -> bool: demand.source_read() # Speculation warms the same scoped runtime entry as the next # foreground request; it does not copy decoded pages into a widget. - window.lookahead.demand.prepare_ahead(window.lookahead, self, staged.page, end) + window.lookahead.demand.prepare_ahead( + window.lookahead, self, staged.page, end + ) await staged.prepare_rows( - self, current, target=self, selected=self.selected_categories, end=end, + self, + current, + target=self, + selected=self.selected_categories, + end=end, boundary=end.edge_boundary(edge, local, staged), - rows_needed=max(1, missing), limit=self.fragment_limit) + rows_needed=max(1, missing), + limit=self.fragment_limit, + ) if not current(): return False async with window.history_lock: @@ -949,14 +1246,19 @@ def current() -> bool: protected = {id(fragment) for fragment in self.visible_fragments()} async with window.preserve_history(root=self): arrivals = self._source_state.arriving(staged, followed_end) - await self._extend_and_trim(edge, end, local, protected, staged, current=current) + await self._extend_and_trim( + edge, end, local, protected, staged, current=current + ) self._require_can_draw() self.repaint(arrivals) self.rows_published() self.state.delivered(self) advanced = end.edge(self.pages) - if (advanced is edge and edge.prepared.capture_range() == page_range - and (edge.prepared.page.before, edge.prepared.page.after) == edge_cursor): + if ( + advanced is edge + and edge.prepared.capture_range() == page_range + and (edge.prepared.page.before, edge.prepared.page.after) == edge_cursor + ): return False if followed_end or not current(): return False @@ -968,8 +1270,13 @@ def current() -> bool: self.window.check_follow() async def _extend_and_trim( - self, edge: TranscriptPageView, end: PageEnd, local: bool, - protected: set[int], prepared: PreparedTranscriptPage, *, + self, + edge: TranscriptPageView, + end: PageEnd, + local: bool, + protected: set[int], + prepared: PreparedTranscriptPage, + *, current: Callable[[], bool], ) -> None: """Load the next range and trim fragments the reader is not seeing. @@ -987,27 +1294,41 @@ async def _extend_and_trim( end.advance_empty(edge.prepared, prepared) await self._page_added(prepared.page, prepared.fragments) elif local: - previous = {id(fragment) for fragment in edge.prepared.fragments_in_range} + previous = { + id(fragment) for fragment in edge.prepared.fragments_in_range + } if not await edge.prepared.extend(edge, prepared, current): raise _DrawingStopped self._require_can_draw() - protected.update(id(fragment) for fragment in edge.prepared.fragments_in_range - if id(fragment) not in previous) + protected.update( + id(fragment) + for fragment in edge.prepared.fragments_in_range + if id(fragment) not in previous + ) else: page_range = edge.prepared.capture_range() async with TranscriptPageView.acquire( - self, prepared, end=end, pages=self.pages, - current=lambda: current() and edge.prepared.capture_range() == page_range, + self, + prepared, + end=end, + pages=self.pages, + current=lambda: current() + and edge.prepared.capture_range() == page_range, ) as view: end.append(self.pages, view) - protected.update(id(fragment) for fragment in view.prepared.fragments_in_range) + protected.update( + id(fragment) for fragment in view.prepared.fragments_in_range + ) protected_pages.add(view) await self._page_added(prepared.page, prepared.fragments) self._fragment_budget = limit = max( - self.budget.item_limit(len(protected)), self._visible_fragment_budget(), + self.budget.item_limit(len(protected)), + self._visible_fragment_budget(), ) excess = self.fragment_count - limit - while (excess > 0 or len(self.pages) > limit) and (self.fragment_count > 1 or len(self.pages) > 1): + while (excess > 0 or len(self.pages) > limit) and ( + self.fragment_count > 1 or len(self.pages) > 1 + ): selected = None for side in end.eviction_order(self): # A visible older projection protects the range between it @@ -1043,6 +1364,7 @@ async def _extend_and_trim( self.filter.canonical_moved(previous_start, overlay_visible) self._update_edges() + class TranscriptHistory(CommittedHistory, LineHistory): """The line history of a saved transcript. @@ -1061,7 +1383,9 @@ def empty_page(cls): @classmethod def empty_source(cls, view, through): - return SavedHistory(view.transcript.get_transcript_page, through, view.app.preparation) + return SavedHistory( + view.transcript.get_transcript_page, through, view.app.preparation + ) async def restore_source(self, source: PreparedPageSource, current) -> None: """Put the parked pages back into the same mounted reader.""" @@ -1072,12 +1396,15 @@ async def restore_source(self, source: PreparedPageSource, current) -> None: retired = tuple(self.pages) self.page_source = source self._source_state = ParkedSourceTranscript(LiveTranscript()) - self.pages = deque(TranscriptPageView(page, batch_size=self.budget.batch_items) - for page in pages) + self.pages = deque( + TranscriptPageView(page, batch_size=self.budget.batch_items) + for page in pages + ) self.filter = FilterState.for_selection(self) projected = source.projection_source if projected.held_pages: from toad.transcript_filter import Filtered + projection = ProjectedTranscriptHistory.from_source(projected, owner=self) self.filter = Filtered(self, projection) await self.notification_view.prepare_history(self, current, window=self.window) @@ -1086,24 +1413,40 @@ async def restore_source(self, source: PreparedPageSource, current) -> None: await self.remove_children(retired) self.acquire_document() + class ProjectedTranscriptHistory(TranscriptHistory): """A filtered source view with the ordinary pager's loading and eviction policy.""" - def __init__(self, owner: TranscriptHistory, source: ProjectedTranscriptSource, - prepared: PreparedTranscriptPage) -> None: + def __init__( + self, + owner: TranscriptHistory, + source: ProjectedTranscriptSource, + prepared: PreparedTranscriptPage, + ) -> None: self._projection_owner = ref(owner) - super().__init__(prepared, source, view=owner.view, budget=owner.budget, - notification_view=owner.notification_view) + super().__init__( + prepared, + source, + view=owner.view, + budget=owner.budget, + notification_view=owner.notification_view, + ) self.add_class("filtered-history-results") @classmethod - def from_source(cls, source: ProjectedTranscriptSource, *, owner: TranscriptHistory): + def from_source( + cls, source: ProjectedTranscriptSource, *, owner: TranscriptHistory + ): if not source.held_pages or source.closed: raise ValueError("No kept projected range") history = cls(owner, source, source.held_pages[0]) - history.pages.extend(TranscriptPageView( - page, batch_size=owner.budget.batch_items, - ) for page in source.held_pages[1:]) + history.pages.extend( + TranscriptPageView( + page, + batch_size=owner.budget.batch_items, + ) + for page in source.held_pages[1:] + ) history._source_state = ParkedSourceTranscript(LiveTranscript()) return history @@ -1117,7 +1460,9 @@ def request_older(self) -> None: async def load_older(self) -> None: if self.older_page_available: - await self.reserve_source_work().execute(self, partial(self._prepare_edge, OLDER_END)) + await self.reserve_source_work().execute( + self, partial(self._prepare_edge, OLDER_END) + ) @property def state(self) -> TranscriptState: @@ -1141,11 +1486,24 @@ def reads(self, source) -> bool: @property def coverage_events(self) -> Iterator[TranscriptEvent]: - return (event for page in self.pages for fragment in page.prepared.fragments for event in fragment.events) + return ( + event + for page in self.pages + for fragment in page.prepared.fragments + for event in fragment.events + ) - async def _page_added(self, page: TranscriptPage, fragments: tuple[TranscriptFragment, ...]) -> None: + async def _page_added( + self, page: TranscriptPage, fragments: tuple[TranscriptFragment, ...] + ) -> None: owner = self._projection_owner() if owner is not None and owner.filter.owns_projection(self): - await owner._page_added(replace(page, events=tuple( - event for fragment in fragments for event in fragment.events - )), fragments) + await owner._page_added( + replace( + page, + events=tuple( + event for fragment in fragments for event in fragment.events + ), + ), + fragments, + ) diff --git a/src/toad/widgets/wire_history.py b/src/toad/widgets/wire_history.py index 30ebda084..83e4aaebd 100644 --- a/src/toad/widgets/wire_history.py +++ b/src/toad/widgets/wire_history.py @@ -10,10 +10,15 @@ from toad.session_scope import SessionScope from toad.transcript_preparation import ClosedHistory, PreparedTranscriptPage from toad.transcript_source_preparation import HistorySourceSnapshot -from toad.widgets.message_notifications import NothingToRead, PaintedRead, VisibleReadMarking +from toad.widgets.message_notifications import ( + NothingToRead, + PaintedRead, + VisibleReadMarking, +) from toad.widgets.transcript_history import LineHistory from toad.wire_history import PreparedWirePage, WirePage, WirePageBuffer, WireSource + class WireHistory(VisibleReadMarking, LineHistory): """The line history of a channel or direct conversation. @@ -25,10 +30,22 @@ class WireHistory(VisibleReadMarking, LineHistory): page_source: WirePageBuffer | ClosedHistory - def __init__(self, prepared: PreparedTranscriptPage, page_source: WirePageBuffer | ClosedHistory, - *, view, notification_view, **kwargs) -> None: - super().__init__(prepared, page_source, view=view, - notification_view=notification_view, **kwargs) + def __init__( + self, + prepared: PreparedTranscriptPage, + page_source: WirePageBuffer | ClosedHistory, + *, + view, + notification_view, + **kwargs, + ) -> None: + super().__init__( + prepared, + page_source, + view=view, + notification_view=notification_view, + **kwargs, + ) # Core's source observation is independent of paint readiness. self.revision = ConversationObservation() @@ -41,7 +58,9 @@ def empty_page(cls): def open(self, source: WireSource) -> None: """Bind the conversation to read; the first refresh draws its newest page.""" self.page_source.close() - self.page_source = WirePageBuffer(source, self.page_source.through, self.app.preparation) + self.page_source = WirePageBuffer( + source, self.page_source.through, self.app.preparation + ) @property def undrawn(self) -> bool: @@ -55,16 +74,21 @@ def source_identity(self): @property def can_draw_on_screen(self) -> bool: - return (super().can_draw_on_screen - and self.view.query_ancestor(SessionScope).is_current) + return ( + super().can_draw_on_screen + and self.view.query_ancestor(SessionScope).is_current + ) async def source_is_current(self, snapshot: HistorySourceSnapshot) -> bool: """Whether the route still selects this source's bus and nothing replaced the history.""" source = self.page_source if not snapshot.current(self): return False - return (await source.route_is_current() - and source is self.page_source and snapshot.current(self)) + return ( + await source.route_is_current() + and source is self.page_source + and snapshot.current(self) + ) @property def selected_categories(self): @@ -84,21 +108,28 @@ async def read_changes(self) -> bool: def rows_published(self) -> None: super().rows_published() - if not self.undrawn and (placeholder := self.view.query_one_optional("#history-loading")): + if not self.undrawn and ( + placeholder := self.view.query_one_optional("#history-loading") + ): placeholder.remove() async def accept_extension(self, edge, prepared) -> bool: if self.page_source.accepts_page(edge.prepared.page, prepared.page): return True prepared.configure(batch_size=len(prepared.fragments)) - await self.show_newest(self.page_source.through, PreparedWirePage.empty(WirePage.unread()), - arrivals=self._source_state.arriving(prepared, self.drawn_tail_followed)) + await self.show_newest( + self.page_source.through, + PreparedWirePage.empty(WirePage.unread()), + arrivals=self._source_state.arriving(prepared, self.drawn_tail_followed), + ) return False def draws(self, message: Message) -> bool: """Whether the drawn rows include this message, checking the newest first.""" - return any(fragment.message.view_key == message.view_key - for fragment in reversed(self.fragments_in_range)) + return any( + fragment.message.view_key == message.view_key + for fragment in reversed(self.fragments_in_range) + ) async def toggle_style(self) -> None: """Draw every message in the other style, here and in pages read from now on.""" @@ -128,7 +159,11 @@ async def _mark_read(self, snapshot: HistorySourceSnapshot) -> None: elif self.is_attached: self.view.call_after_refresh(self.mark_visible) return - seen = tuple(fragment for fragment in self.read_fragments() if fragment.evidence is not None) + seen = tuple( + fragment + for fragment in self.read_fragments() + if fragment.evidence is not None + ) if (selected := self.page_source.seen_page(self, seen)) is not None: await self._mark_page(*selected, snapshot) diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index e3c74af7d..3f1013602 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -17,10 +17,17 @@ from agent_comms.comms import Comms from agent_comms.message_page import MessagePage from agent_comms.transcript_events import IncomingTranscript -from agent_comms.page_traversal import LiveCursor, NewerTraversal, OlderTraversal, PageCursor +from agent_comms.page_traversal import ( + LiveCursor, + NewerTraversal, + OlderTraversal, + PageCursor, +) from agent_comms.messages import Message, ConversationRole, NoticeRole, Membership from agent_comms.historical_views import ( - ConversationHistoryView, ConversationObservation, ConversationRevision, + ConversationHistoryView, + ConversationObservation, + ConversationRevision, ) from toad.conversation_kind import ConversationKind @@ -28,10 +35,19 @@ from toad.line_blocks import Body, Gap, LineBlock, MutedRole, Summary from toad.message_rows import MessageRow, WireMessageStyle from toad.transcript_preparation import ( - PageRequest, PreparedTranscriptPage, SavedHistory, SourceObservation, TranscriptPageWork, Unchanged, + PageRequest, + PreparedTranscriptPage, + SavedHistory, + SourceObservation, + TranscriptPageWork, + Unchanged, ) from toad.widgets.message_notifications import PaintedRead, ReadWork -from toad.widgets.transcript_fragments import BusMessageFragment, MessageShown, TranscriptFragment +from toad.widgets.transcript_fragments import ( + BusMessageFragment, + MessageShown, + TranscriptFragment, +) if TYPE_CHECKING: from agent_comms.read_basis import NamedViewer @@ -132,9 +148,18 @@ def advanced(self, interval: WirePage, *, older: bool) -> WirePage: must adopt the actual empty Core page rather than remain unobserved. """ source = interval.source if self.undrawn else self.source - return (replace(self, before=interval.before, has_older=interval.has_older, source=source) - if older else replace(self, after=interval.after, has_newer=interval.has_newer, - source=source)) + return ( + replace( + self, + before=interval.before, + has_older=interval.has_older, + source=source, + ) + if older + else replace( + self, after=interval.after, has_newer=interval.has_newer, source=source + ) + ) class PreparedWirePage(PreparedTranscriptPage): @@ -206,8 +231,11 @@ def text_rows(self, lines: Lines) -> range: """Read visibility uses the same block snapshots that draw this viewport.""" prepared = lines.prepared_blocks trailing = len(self.trailing(self.shown)) - return range(sum(block.height for block in prepared[:len(self.leading)]), - lines.height - sum(block.height for block in prepared[-trailing:] if trailing)) + return range( + sum(block.height for block in prepared[: len(self.leading)]), + lines.height + - sum(block.height for block in prepared[-trailing:] if trailing), + ) class MembershipFragment(WireFragment, roles=(Membership,)): @@ -218,13 +246,19 @@ def text(self, shown): @dataclass(kw_only=True) -class MessageFragment(BusMessageFragment, WireFragment, roles=(ConversationRole, NoticeRole)): +class MessageFragment( + BusMessageFragment, WireFragment, roles=(ConversationRole, NoticeRole) +): """A sent message: its header, text and the state of its notifications.""" @classmethod def from_message(cls, message, *, reader, **fields): - return cls((IncomingTranscript(message=message, reader=reader),), - text_part=message.body, row=MessageRow(message, reader), **fields) + return cls( + (IncomingTranscript(message=message, reader=reader),), + text_part=message.body, + row=MessageRow(message, reader), + **fields, + ) @property def notification_references(self): @@ -236,8 +270,15 @@ def leading(self): source = message.history_source if source is None: return super().leading - return (Gap(), Summary.titled(f"History · {source.original_root} · @{message.sender} " - f"created {message.sender_created_at}"), self.row.header) + return ( + Gap(), + Summary.titled( + f"History · {source.original_root} · @{message.sender} " + f"created {message.sender_created_at}" + ), + self.row.header, + ) + @dataclass(frozen=True, eq=False) class WireSource: @@ -263,7 +304,11 @@ def viewer(self) -> NamedViewer: def observe(self, seen: ConversationObservation) -> SourceObservation: """The observed state owns how changed or unchanged history is drawn.""" revision = self.conversation.history_view.revision( - self.comms.views, self.conversation.target.name, seen, worktree=str(self.project)) + self.comms.views, + self.conversation.target.name, + seen, + worktree=str(self.project), + ) if revision == seen: return Unchanged() high = AfterSequence(LiveCursor(self.comms.views.latest_sequence())) @@ -272,18 +317,32 @@ def observe(self, seen: ConversationObservation) -> SourceObservation: replaced = revision.display_changed_since(seen) return WireObservation(revision, high, replaced) - def read(self, request: PageRequest[WireCut], through: AfterSequence) -> PreparedTranscriptPage: + def read( + self, request: PageRequest[WireCut], through: AfterSequence + ) -> PreparedTranscriptPage: """Read the page a request names, never past `through`, as fragments to draw.""" page = request.end.wire_page(self, request.cursor, through) shown = MessageShown(style=self.style) fragments = tuple( - WireFragment.of(message, shown=shown, evidence=page.evidence_for(message), reader=self.me) - for message in page.messages) + WireFragment.of( + message, + shown=shown, + evidence=page.evidence_for(message), + reader=self.me, + ) + for message in page.messages + ) wire = WirePage( page.messages, BeforeMessage(page.messages[0].view_cursor) if page.messages else None, - AfterMessage(page.messages[-1].view_cursor) if page.has_newer and page.messages else through, - page.has_older and bool(page.messages), page.has_newer and bool(page.messages), page, + ( + AfterMessage(page.messages[-1].view_cursor) + if page.has_newer and page.messages + else through + ), + page.has_older and bool(page.messages), + page.has_newer and bool(page.messages), + page, ) return PreparedWirePage.measured(wire, fragments) @@ -300,7 +359,9 @@ class WireObservation(SourceObservation): async def apply(self, history, source) -> bool: history.revision = self.revision if self.replaced: - await history.show_newest(self.through, PreparedWirePage.empty(WirePage.unread())) + await history.show_newest( + self.through, PreparedWirePage.empty(WirePage.unread()) + ) return not history.undrawn if not history.undrawn: return await history.extend(self.through) @@ -319,7 +380,9 @@ def retain_result(self) -> bool: async def execute(self, runtime, key): import asyncio - prepared = await runtime.run_thread(self.loader.read, self.request, self.through) + prepared = await runtime.run_thread( + self.loader.read, self.request, self.through + ) if self.scope.closed: raise asyncio.CancelledError return await runtime.run_thread(self.finish_result, key, prepared) @@ -336,7 +399,9 @@ def uses_observation(self, observation) -> bool: async def route_is_current(self) -> bool: from agent_comms.route_selection import root_is_current - return not self.closed and await self.runtime.run_thread(root_is_current, self.loader.comms.root) + return not self.closed and await self.runtime.run_thread( + root_is_current, self.loader.comms.root + ) def status_thread(self, access): return self.loader.conversation.status_thread(self.loader.comms.root, access) @@ -344,16 +409,29 @@ def status_thread(self, access): def command_context(self, view): from toad.target_commands import TargetContext - return TargetContext(view.app, self.loader.comms, self.loader.conversation.target.name, self.loader.me, - self.loader.project, view.query_ancestor(SessionScope).id) + return TargetContext( + view.app, + self.loader.comms, + self.loader.conversation.target.name, + self.loader.me, + self.loader.project, + view.query_ancestor(SessionScope).id, + ) async def submit_input(self, view, event) -> None: conversation = self.loader.conversation - current = lambda: (view.is_attached and self is view.message_history.page_source - and conversation == view.conversation) + current = lambda: ( + view.is_attached + and self is view.message_history.page_source + and conversation == view.conversation + ) await view.submit_comms_message( - self.loader.comms.root, conversation, event.body, current, - partial(view._show_sent, body=event.body)) + self.loader.comms.root, + conversation, + event.body, + current, + partial(view._show_sent, body=event.body), + ) def schedule_refresh(self, view): history = view.message_history @@ -367,8 +445,12 @@ async def change_style(self, history) -> None: return style = self.loader.style.next() history.open(self.loader.restyled(style)) - await history._show([(fragment, replace(fragment.shown, style=style)) - for fragment in history.fragments]) + await history._show( + [ + (fragment, replace(fragment.shown, style=style)) + for fragment in history.fragments + ] + ) history.window.scroll_end(animate=False) async def read_revision(self): @@ -388,19 +470,25 @@ async def mark_page(self, page) -> None: comms = self.loader.comms await asyncio.to_thread( - run_selected_write, comms.root, comms.views.mark_page_read, page, - worktree=str(self.loader.project)) + run_selected_write, + comms.root, + comms.views.mark_page_read, + page, + worktree=str(self.loader.project), + ) async def read_only(self) -> bool: return await self.runtime.run_thread( - self.loader.conversation.read_only, self.loader.comms) + self.loader.conversation.read_only, self.loader.comms + ) async def update_mentions(self, view) -> None: await self.loader.conversation.update_mentions(view, self.loader.comms) async def status_text(self, *, read_only): - return await self.loader.conversation.status_text(self.loader.comms, - read_only=read_only) + return await self.loader.conversation.status_text( + self.loader.comms, read_only=read_only + ) def accepts(self, loader, through) -> bool: # Earlier wire cuts stay valid while the bus grows. diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 8266f26e1..af224014a 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -1,4 +1,5 @@ """Fixed native workspace chrome; logical session switches never reparent it.""" + from typing import TYPE_CHECKING from textual.app import ComposeResult from textual.containers import Horizontal @@ -26,6 +27,7 @@ class WorkspaceHeader(TonePaint, WidgetView, Horizontal): def compose(self) -> ComposeResult: from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import TabHistoryControls + yield TabHistoryControls() yield SessionsTabs() @@ -49,38 +51,58 @@ async def prepare_navigation(self, screen) -> bool: def refresh_layout(self) -> None: """Placement changes retire both native parent arrangements.""" source = self.channels.app.workspace_sessions.source - for parent in {bar.parent for bar in (self.channels, *source.sidebars()) if bar.is_mounted}: + for parent in { + bar.parent for bar in (self.channels, *source.sidebars()) if bar.is_mounted + }: parent.refresh(repaint=False, layout=True) - def arrange(self, parent: Widget, children: list[Widget], size: Size, - greedy: bool = True) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True + ) -> ArrangeResult: """Place chrome and conversation directly in the native layout pass.""" source = parent.app.workspace_sessions.source layout = parent.app.sidebar_layout own = tuple(source.sidebars()) - bars = tuple(bar for bar in (self.channels, *own) - if bar.presentation_visible and bar.id in layout.placements) + bars = tuple( + bar + for bar in (self.channels, *own) + if bar.presentation_visible and bar.id in layout.placements + ) resolved = layout.resolve(size.width, {bar.id: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. nested = parent is self.channels.parent and any( - bar.parent.layout is self for bar in own) + bar.parent.layout is self for bar in own + ) left = 0 if nested else resolved.left_gutter right = 0 if nested else resolved.right_gutter - placements = [placement._replace( - region=placement.region + Offset(left, 0), - margin=placement.margin + Spacing(0, right, 0, left), - ) for placement in super().arrange( - parent, [child for child in children if child not in bars], - Size(max(0, size.width - left - right), size.height), greedy)] + placements = [ + placement._replace( + region=placement.region + Offset(left, 0), + margin=placement.margin + Spacing(0, right, 0, left), + ) + for placement in super().arrange( + parent, + [child for child in children if child not in bars], + Size(max(0, size.width - left - right), size.height), + greedy, + ) + ] order = {identity: index for index, identity in enumerate(layout.ordered())} for bar in bars: if bar in children: geometry = resolved.bars[bar.id] - placements.append(WidgetPlacement( - Region(0, 0, geometry.width, size.height), Offset(geometry.x, 0), - Spacing(), bar, order[bar.id], overlay=True, absolute=True, - )) + placements.append( + WidgetPlacement( + Region(0, 0, geometry.width, size.height), + Offset(geometry.x, 0), + Spacing(), + bar, + order[bar.id], + overlay=True, + absolute=True, + ) + ) return placements async def select(self, view: "SessionScope") -> None: diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 899c04c72..7949f2af0 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -1,4 +1,5 @@ """Logical session membership; Textual owns only the persistent native frame.""" + from collections.abc import Callable from abc import abstractmethod from typing import TYPE_CHECKING @@ -7,7 +8,12 @@ from agent_comms.declared_family import DeclaredFamily from toad.constants import WELCOME -from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope, ViewSessionOrigin +from toad.session_scope import ( + NoSessionOrigin, + SessionOrigin, + SessionScope, + ViewSessionOrigin, +) from toad.navigation_target import NavigationTarget, NoTarget if TYPE_CHECKING: @@ -148,15 +154,21 @@ def __init__(self, app: "ToadApp") -> None: @property def native_origins(self) -> dict[str, ViewSessionOrigin]: """Derive actual native membership from the existing mounted-view owner.""" - return {identity: view for identity, view in self.views.items() - if isinstance(view, ViewSessionOrigin)} + return { + identity: view + for identity, view in self.views.items() + if isinstance(view, ViewSessionOrigin) + } def require(self, identity: str) -> "SessionScope": return self.views[identity] def origin(self, identity: str) -> SessionOrigin: """The original source, or its explicit unavailable member once closed.""" - if self.app.session_navigation.get(identity) is not None or identity in self.resident: + if ( + self.app.session_navigation.get(identity) is not None + or identity in self.resident + ): if view := self.views.get(identity): return view.origin return NoSessionOrigin(identity) @@ -207,12 +219,20 @@ def owns(self, view: "SessionScope") -> bool: def visible_target(self, root) -> NavigationTarget: """The selected logical destination, including while a modal covers its frame.""" view = self.source.view - return self.target_for_mode(view.id, root) if view is not None and self.owns(view) else NoTarget() + return ( + self.target_for_mode(view.id, root) + if view is not None and self.owns(view) + else NoTarget() + ) def target_for_mode(self, identity, root) -> NavigationTarget: """Resolve a transition's destination from its original workspace view.""" view = self.views.get(identity) - return view.navigation_target if root is not None and view is not None and view.belongs_to_wire(root) else NoTarget() + return ( + view.navigation_target + if root is not None and view is not None and view.belongs_to_wire(root) + else NoTarget() + ) def release_frame_callback(self, owner, callback) -> bool: """Admit mounted work through its original logical source binding. diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index ede071206..3cab3f0da 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -28,7 +28,9 @@ async def test_scope_import_and_real_conversation_ancestor(workspace): await ui.press("enter") await ui.until_shows("PEER_ANSWER session scope journey") assert app.workspace_sessions.native_origins[first] is view - assert view.matches_launch(definition.identity, ui.conversation.agent_binding.session_id) + assert view.matches_launch( + definition.identity, ui.conversation.agent_binding.session_id + ) sidebar = app.workspace_chrome.channels if sidebar.collapsed: sidebar.toggle() diff --git a/tests/app/test_welcome_view.py b/tests/app/test_welcome_view.py index 400e54434..5cbdff99b 100644 --- a/tests/app/test_welcome_view.py +++ b/tests/app/test_welcome_view.py @@ -24,7 +24,9 @@ async def test_starting_opens_no_tab_and_starts_no_agent(workspace): app, _ = ui.textual assert app.workspace_sessions.native_origins == {} assert ui.shows(HINT) and ui.shows(CHANNELS) - assert not workspace.child_processes("echo.py"), "starting Toad started its agent" + assert not workspace.child_processes( + "echo.py" + ), "starting Toad started its agent" assert not workspace.received() assert workspace.project.name not in workspace.comms.registry.snapshot().threads @@ -40,11 +42,19 @@ async def test_opening_a_channel_opens_its_tab_in_place_of_the_view(workspace): assert not ui.shows(HINT) -async def test_opening_a_thread_run_elsewhere_opens_its_history_read_as_the_user(workspace): +async def test_opening_a_thread_run_elsewhere_opens_its_history_read_as_the_user( + workspace, +): from agent_comms.thread_execution import ExternalThreadExecution - workspace.comms.registry.declare(workspace.comms.registry.new_thread( - "scribe", frozenset({"team"}), str(workspace.project), execution=ExternalThreadExecution)) + workspace.comms.registry.declare( + workspace.comms.registry.new_thread( + "scribe", + frozenset({"team"}), + str(workspace.project), + execution=ExternalThreadExecution, + ) + ) async with workspace.app(session=False) as ui: await ui.until_shows(THREADS) await focus_row(ui, "ThreadRow") @@ -64,7 +74,10 @@ async def test_closing_the_last_tab_shows_the_view_again_and_starts_no_agent(wor await ui.close_tab(ui.selected_tab) await ui.until_shows(HINT) assert ui.tabs() == () - await ui.until(lambda: not workspace.child_processes("echo.py"), why="the closed session's agent to be gone") + await ui.until( + lambda: not workspace.child_processes("echo.py"), + why="the closed session's agent to be gone", + ) async def test_going_back_from_the_only_tab_stays_on_it(workspace): diff --git a/tests/model/test_conversation_kind.py b/tests/model/test_conversation_kind.py index a3cc18a90..e7e7eab1f 100644 --- a/tests/model/test_conversation_kind.py +++ b/tests/model/test_conversation_kind.py @@ -14,7 +14,11 @@ from agent_comms.message_page import PageBounds from toad.constants import ALL_COMMS_TARGET from toad.conversation_kind import ( - ChannelConversation, ConversationKind, DmConversation, IrcConversation, ResolvedConversation, + ChannelConversation, + ConversationKind, + DmConversation, + IrcConversation, + ResolvedConversation, ) from toad.session_tracker import CommsViewKey from toad.message_rows import IrcMessageStyle @@ -24,7 +28,9 @@ @pytest.fixture def copied_conversations(tmp_path): original = wire(tmp_path / "original") - original.registry.declare(original.registry.new_thread("peer", frozenset({"team"}), str(tmp_path))) + original.registry.declare( + original.registry.new_thread("peer", frozenset({"team"}), str(tmp_path)) + ) viewer = original.messaging.user_identity(str(tmp_path)).name original.messaging.send("peer", "#team", "channel body") original.messaging.send("peer", viewer, "direct body") @@ -33,13 +39,24 @@ def copied_conversations(tmp_path): return Comms(tmp_path / "copied"), viewer -@pytest.mark.parametrize("conversation,label,body", [ - (DmConversation(ThreadTarget("peer")), "@peer", "direct body"), - (ChannelConversation(ChannelTarget("#team")), "#team", "channel body"), - (IrcConversation(ChannelTarget(ALL_COMMS_TARGET)), f"{ALL_COMMS_TARGET} · all comms", "direct body"), -]) +@pytest.mark.parametrize( + "conversation,label,body", + [ + (DmConversation(ThreadTarget("peer")), "@peer", "direct body"), + (ChannelConversation(ChannelTarget("#team")), "#team", "channel body"), + ( + IrcConversation(ChannelTarget(ALL_COMMS_TARGET)), + f"{ALL_COMMS_TARGET} · all comms", + "direct body", + ), + ], +) def test_typed_conversation_resolves_pages_and_labels_on_a_copied_bus( - copied_conversations, tmp_path, conversation, label, body, + copied_conversations, + tmp_path, + conversation, + label, + body, ): comms, viewer = copied_conversations resolved = conversation.resolve(comms, viewer) @@ -47,15 +64,20 @@ def test_typed_conversation_resolves_pages_and_labels_on_a_copied_bus( assert resolved.conversation.label() == label assert resolved.conversation.target == conversation.target page = resolved.conversation.page( - WireSource(comms, resolved.conversation, tmp_path, viewer, IrcMessageStyle()), PageBounds(limit=8), + WireSource(comms, resolved.conversation, tmp_path, viewer, IrcMessageStyle()), + PageBounds(limit=8), ) assert body in [message.body for message in page.messages] - assert FieldCodec.decode(type[ConversationKind], FieldCodec.encode(type(conversation))) is type(conversation) + assert FieldCodec.decode( + type[ConversationKind], FieldCodec.encode(type(conversation)) + ) is type(conversation) with pytest.raises(FrozenInstanceError): resolved.viewer = "another-viewer" -def test_new_channel_kind_inherits_its_typed_destination_and_resolution(copied_conversations): +def test_new_channel_kind_inherits_its_typed_destination_and_resolution( + copied_conversations, +): class TeamConversation(ChannelConversation): pass @@ -72,25 +94,34 @@ def test_conversation_value_is_frozen_and_its_kind_is_part_of_tab_identity(): with pytest.raises(FrozenInstanceError): conversation.target = ChannelTarget("#other") key = CommsViewKey("/wire", "owner", conversation) - assert key == CommsViewKey("/wire", "owner", ChannelConversation(ChannelTarget("#team"))) - assert key != CommsViewKey("/wire", "owner", IrcConversation(ChannelTarget("#team"))) + assert key == CommsViewKey( + "/wire", "owner", ChannelConversation(ChannelTarget("#team")) + ) + assert key != CommsViewKey( + "/wire", "owner", IrcConversation(ChannelTarget("#team")) + ) assert key != CommsViewKey("/wire", "owner", DmConversation(ThreadTarget("peer"))) assert len({key, CommsViewKey("/wire", "owner", conversation)}) == 1 assert conversation.view_identity(key) == conversation.view_identity( - CommsViewKey("/wire", "another-owner", conversation)) + CommsViewKey("/wire", "another-owner", conversation) + ) direct = DmConversation(ThreadTarget("peer")) - assert direct.view_identity(CommsViewKey("/wire", "owner", direct)) != direct.view_identity( - CommsViewKey("/wire", "another-owner", direct)) + assert direct.view_identity( + CommsViewKey("/wire", "owner", direct) + ) != direct.view_identity(CommsViewKey("/wire", "another-owner", direct)) def test_conversation_resolves_a_renamed_peer_before_presentation(tmp_path): comms = wire(tmp_path / "wire") - comms.registry.declare(comms.registry.new_thread("peer", frozenset(), str(tmp_path))) + comms.registry.declare( + comms.registry.new_thread("peer", frozenset(), str(tmp_path)) + ) viewer = comms.messaging.user_identity(str(tmp_path)).name comms.registry.rename("peer", "renamed") conversation = DmConversation(ThreadTarget("peer")) assert conversation.resolve(comms, viewer) == ResolvedConversation( - viewer, DmConversation(ThreadTarget("renamed")), + viewer, + DmConversation(ThreadTarget("renamed")), ) assert conversation.target == ThreadTarget("peer") @@ -102,5 +133,10 @@ def test_conversation_selects_the_saved_history_identity_and_channel_status(tmp_ create_view = partial(HistoricalSessions, comms, ()) channel = ChannelConversation(ChannelTarget("#team")) assert channel.historical_sessions(create_view).initial_name is None - assert DmConversation(ThreadTarget("peer")).historical_sessions(create_view).initial_name == "peer" + assert ( + DmConversation(ThreadTarget("peer")) + .historical_sessions(create_view) + .initial_name + == "peer" + ) assert isinstance(channel.status_thread(comms.root, object()), NoStatusThread) diff --git a/tests/native/test_native_client_requests.py b/tests/native/test_native_client_requests.py index da47458ef..628fa139a 100644 --- a/tests/native/test_native_client_requests.py +++ b/tests/native/test_native_client_requests.py @@ -1,5 +1,6 @@ """Requests the agent makes of its client (files, permission, terminal) on a real native session, -and what happens to them when the tab is left, the session is replaced or the agent is stopped.""" +and what happens to them when the tab is left, the session is replaced or the agent is stopped. +""" import asyncio @@ -14,9 +15,16 @@ REJECT = {"optionId": "reject", "name": "Reject", "kind": "reject_once"} -async def test_client_requests_follow_the_session_they_belong_to(workspace, native_package): +async def test_client_requests_follow_the_session_they_belong_to( + workspace, native_package +): async with native_session(workspace, native_package) as native: - ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider + ui, agent, comms, provider = ( + native.ui, + native.agent, + native.comms, + native.provider, + ) app, _pilot = ui.textual view = ui.conversation owner_tab = ui.selected_tab @@ -24,42 +32,102 @@ async def test_client_requests_follow_the_session_they_belong_to(workspace, nati await ui.until_agent_ready() await ui.write_draft("CLIENT_SESSION_SAVED_HISTORY") await ui.press("enter") - await ui.until(lambda: "NATIVE_RESPONSE_1" in ui.painted(view.window), seconds=30, why="the answer") - await ui.until(lambda: not native.executing, seconds=20, why="the turn to finish") + await ui.until( + lambda: "NATIVE_RESPONSE_1" in ui.painted(view.window), + seconds=30, + why="the answer", + ) + await ui.until( + lambda: not native.executing, seconds=20, why="the turn to finish" + ) await agent.session.reconnect() assert agent.session.ready - await ui.until(lambda: "NATIVE_RESPONSE_1" in ui.painted(view.window), seconds=20, why="history after reattaching") + await ui.until( + lambda: "NATIVE_RESPONSE_1" in ui.painted(view.window), + seconds=20, + why="history after reattaching", + ) calls = iter(range(101, 200)) async def request(method, **params): - result = await agent.server.call({"jsonrpc": "2.0", "id": next(calls), "method": method, - "params": {"sessionId": agent.session_id, **params}}) + result = await agent.server.call( + { + "jsonrpc": "2.0", + "id": next(calls), + "method": method, + "params": {"sessionId": agent.session_id, **params}, + } + ) assert "error" not in result, result return result["result"] def reviewing(): return isinstance(app.screen, PermissionReview) - await request("fs/write_text_file", path="client.txt", content="old client value\nretained line") - assert (await request("fs/read_text_file", path="client.txt", line=2, limit=1))["content"] == "retained line" - tool = {"toolCallId": "client-edit", "title": "CLIENT_BOUND_PERMISSION", "kind": "edit", - "content": [{"type": "diff", "path": str(project / "client.txt"), - "oldText": "old client value", "newText": "CLIENT_NEW_VALUE"}]} - agent.updates.accept(agent.session_id, {"sessionUpdate": "tool_call", **tool, "status": "in_progress"}) - permission = asyncio.create_task(request( - "session/request_permission", options=[ALLOW, REJECT], toolCall={"toolCallId": "client-edit"})) + await request( + "fs/write_text_file", + path="client.txt", + content="old client value\nretained line", + ) + assert (await request("fs/read_text_file", path="client.txt", line=2, limit=1))[ + "content" + ] == "retained line" + tool = { + "toolCallId": "client-edit", + "title": "CLIENT_BOUND_PERMISSION", + "kind": "edit", + "content": [ + { + "type": "diff", + "path": str(project / "client.txt"), + "oldText": "old client value", + "newText": "CLIENT_NEW_VALUE", + } + ], + } + agent.updates.accept( + agent.session_id, + {"sessionUpdate": "tool_call", **tool, "status": "in_progress"}, + ) + permission = asyncio.create_task( + request( + "session/request_permission", + options=[ALLOW, REJECT], + toolCall={"toolCallId": "client-edit"}, + ) + ) await ui.until(reviewing, why="the permission review") - await ui.until(lambda: ui.shows("CLIENT_NEW_VALUE") and ui.shows("Allow"), why="the proposed edit and its choices") + await ui.until( + lambda: ui.shows("CLIENT_NEW_VALUE") and ui.shows("Allow"), + why="the proposed edit and its choices", + ) await ui.press("a") - assert (await permission)["outcome"] == {"outcome": "selected", "optionId": "allow"} - await request("fs/write_text_file", path="client.txt", content="CLIENT_NEW_VALUE\nretained line") + assert (await permission)["outcome"] == { + "outcome": "selected", + "optionId": "allow", + } + await request( + "fs/write_text_file", + path="client.txt", + content="CLIENT_NEW_VALUE\nretained line", + ) assert (project / "client.txt").read_text().startswith("CLIENT_NEW_VALUE") - terminal_id = (await request("terminal/create", command="sh", - args=["-c", "printf 'CLIENT_TERMINAL_RETAINED\\n'"]))["terminalId"] - assert (await request("terminal/wait_for_exit", terminalId=terminal_id))["exitCode"] == 0 - assert "CLIENT_TERMINAL_RETAINED" in (await request("terminal/output", terminalId=terminal_id))["output"] + terminal_id = ( + await request( + "terminal/create", + command="sh", + args=["-c", "printf 'CLIENT_TERMINAL_RETAINED\\n'"], + ) + )["terminalId"] + assert (await request("terminal/wait_for_exit", terminalId=terminal_id))[ + "exitCode" + ] == 0 + assert ( + "CLIENT_TERMINAL_RETAINED" + in (await request("terminal/output", terminalId=terminal_id))["output"] + ) await ui.until_shows("CLIENT_TERMINAL_RETAINED") execution = agent.controller.terminals.require(terminal_id) @@ -68,12 +136,20 @@ def reviewing(): document = view.prompt.prompt_text_area.document undo = view.prompt.prompt_text_area.history user = comms.messaging.user_identity(str(project)).name - await channel_target("#team").open(NavigationContext(app, owner_tab, project, user)) + await channel_target("#team").open( + NavigationContext(app, owner_tab, project, user) + ) await ui.select_tab(owner_tab) returned = ui.conversation - assert returned.agent is agent and agent.controller.terminals.require(terminal_id) is execution - await ui.until(lambda: returned.query_one_optional(f"#{terminal_id}", TerminalTool) is not None, - why="the terminal block after returning") + assert ( + returned.agent is agent + and agent.controller.terminals.require(terminal_id) is execution + ) + await ui.until( + lambda: returned.query_one_optional(f"#{terminal_id}", TerminalTool) + is not None, + why="the terminal block after returning", + ) await ui.until_shows("CLIENT_TERMINAL_RETAINED") assert ui.draft() == "CLIENT_SESSION_UNSENT_DRAFT" assert returned.prompt.prompt_text_area.document is document @@ -81,11 +157,15 @@ def reviewing(): await request("terminal/release", terminalId=terminal_id) # A pending permission survives the view being detached and is shown again when it returns. - pending = asyncio.create_task(request( - "session/request_permission", options=[ALLOW, REJECT], - toolCall={**tool, "toolCallId": "retained-permission"})) + pending = asyncio.create_task( + request( + "session/request_permission", + options=[ALLOW, REJECT], + toolCall={**tool, "toolCallId": "retained-permission"}, + ) + ) await ui.until(reviewing, why="the second permission review") - owned, = agent.permissions.pending + (owned,) = agent.permissions.pending agent.detach_surface(returned) await ui.until(lambda: not reviewing(), why="the review to close with its view") assert owned.pending and not agent.controller.surface.owns(returned) @@ -94,11 +174,19 @@ def reviewing(): await ui.until_shows("CLIENT_NEW_VALUE") assert agent.permissions.pending == (owned,) await ui.press("r") - assert (await pending)["outcome"] == {"outcome": "selected", "optionId": "reject"} + assert (await pending)["outcome"] == { + "outcome": "selected", + "optionId": "reject", + } # Replacing the session cancels its pending permission. - replaced = asyncio.create_task(request( - "session/request_permission", options=[ALLOW], toolCall={**tool, "toolCallId": "replaced-permission"})) + replaced = asyncio.create_task( + request( + "session/request_permission", + options=[ALLOW], + toolCall={**tool, "toolCallId": "replaced-permission"}, + ) + ) await ui.until(reviewing, why="the third permission review") first_session = agent.session_id await agent.session.new() @@ -110,10 +198,17 @@ def reviewing(): assert (await replaced)["outcome"] == {"outcome": "cancelled"} # Stopping the agent cancels its pending permission and releases its terminals. - stopped = asyncio.create_task(request( - "session/request_permission", options=[ALLOW], toolCall={**tool, "toolCallId": "stopped-permission"})) + stopped = asyncio.create_task( + request( + "session/request_permission", + options=[ALLOW], + toolCall={**tool, "toolCallId": "stopped-permission"}, + ) + ) await ui.until(reviewing, why="the fourth permission review") await agent.stop() assert (await stopped)["outcome"] == {"outcome": "cancelled"} - assert not agent.permissions.pending and not agent.controller.terminals.executions + assert ( + not agent.permissions.pending and not agent.controller.terminals.executions + ) assert len(provider.requests) == 1 diff --git a/tests/native/test_native_editor_queue.py b/tests/native/test_native_editor_queue.py index d2b732807..f61abe122 100644 --- a/tests/native/test_native_editor_queue.py +++ b/tests/native/test_native_editor_queue.py @@ -10,9 +10,16 @@ from toad.widgets.transcript_history import TranscriptHistory -async def test_enter_queues_during_a_turn_and_a_tab_click_returns_to_the_same_editor(workspace, native_package): +async def test_enter_queues_during_a_turn_and_a_tab_click_returns_to_the_same_editor( + workspace, native_package +): async with native_session(workspace, native_package) as native: - ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider + ui, agent, comms, provider = ( + native.ui, + native.agent, + native.comms, + native.provider, + ) app, pilot = ui.textual view = ui.conversation owner_tab = ui.selected_tab @@ -20,27 +27,53 @@ async def test_enter_queues_during_a_turn_and_a_tab_click_returns_to_the_same_ed provider.hold_next_request() await ui.write_draft("NATIVE_EDITOR_FIRST") await ui.press("enter") - await ui.until(provider.entered.is_set, seconds=30, why="the first input at the provider") + await ui.until( + provider.entered.is_set, seconds=30, why="the first input at the provider" + ) await ui.until(lambda: native.executing, seconds=20, why="the running turn") await ui.write_draft("NATIVE_EDITOR_QUEUED") await ui.press("enter") - await ui.until(lambda: bool(view.submissions.queue_projection.items), seconds=20, why="the queued input") + await ui.until( + lambda: bool(view.submissions.queue_projection.items), + seconds=20, + why="the queued input", + ) assert "NATIVE_EDITOR_QUEUED" in view.query_one(QueueSummary).render().plain provider.release() - await ui.until(lambda: len(provider.requests) == 2 and not native.executing, seconds=35, - why="both turns to finish") - await ui.until(lambda: "NATIVE_RESPONSE_2" in ui.painted(view.window), seconds=25, why="the second answer") - await ui.until(lambda: not view.submissions.queue_projection.items and view.turns.managed_id is None, - seconds=20, why="an empty queue and no turn") + await ui.until( + lambda: len(provider.requests) == 2 and not native.executing, + seconds=35, + why="both turns to finish", + ) + await ui.until( + lambda: "NATIVE_RESPONSE_2" in ui.painted(view.window), + seconds=25, + why="the second answer", + ) + await ui.until( + lambda: not view.submissions.queue_projection.items + and view.turns.managed_id is None, + seconds=20, + why="an empty queue and no turn", + ) assert ui.agent_ready() - await ui.until(lambda: all(history.checkpoint_available - for history in view.contents.query(TranscriptHistory)), seconds=20, - why="saved history to be available") + await ui.until( + lambda: all( + history.checkpoint_available + for history in view.contents.query(TranscriptHistory) + ), + seconds=20, + why="saved history to be available", + ) view.window.anchor() view.transcript.require_checkpoint() - await ui.until(lambda: not view.transcript.dirty and "NATIVE_RESPONSE_2" in ui.painted(view.window), - seconds=20, why="the answer from saved history") + await ui.until( + lambda: not view.transcript.dirty + and "NATIVE_RESPONSE_2" in ui.painted(view.window), + seconds=20, + why="the answer from saved history", + ) assert view.contents.query(TranscriptHistory) assert view.transcript.displayed_cursor is not None @@ -49,31 +82,60 @@ async def test_enter_queues_during_a_turn_and_a_tab_click_returns_to_the_same_ed document, undo = editor.document, editor.history owner = comms.registry.require("beta").process_identity assert await pilot.click(view.prompt.query_one(AgentInfo)) - await ui.until(lambda: view.prompt.model_switcher.search_input.has_focus, why="the model picker") + await ui.until( + lambda: view.prompt.model_switcher.search_input.has_focus, + why="the model picker", + ) await ui.press("down", "enter") - await ui.until(lambda: isinstance(app.screen, ContextMenu), why="the thinking menu") - high = next(item for item in app.screen.query("ContextMenuItem") if item.action == "high") - await ui.until(lambda: high in app.screen._compositor.visible_widgets and high.region.width > 0, - why="the 'high' menu item") + await ui.until( + lambda: isinstance(app.screen, ContextMenu), why="the thinking menu" + ) + high = next( + item + for item in app.screen.query("ContextMenuItem") + if item.action == "high" + ) + await ui.until( + lambda: high in app.screen._compositor.visible_widgets + and high.region.width > 0, + why="the 'high' menu item", + ) assert await pilot.click(high, offset=(1, 0)) - await ui.until(lambda: agent.configuration.thinking.current == "high", seconds=20, why="the thinking level") + await ui.until( + lambda: agent.configuration.thinking.current == "high", + seconds=20, + why="the thinking level", + ) assert comms.registry.require("beta").thinking_level is HighThinkingLevel user = comms.messaging.user_identity(str(native.project)).name - await channel_target("#team").open(NavigationContext(app, owner_tab, native.project, user)) + await channel_target("#team").open( + NavigationContext(app, owner_tab, native.project, user) + ) await app.selected_session.wait_content_ready() assert ui.selected_tab != owner_tab assert await pilot.click(f"SessionLabel#{owner_tab}") - await ui.until(lambda: ui.selected_tab == owner_tab and "NATIVE_RESPONSE_2" in ui.painted(view.window), - seconds=20, why="the session after clicking its tab") + await ui.until( + lambda: ui.selected_tab == owner_tab + and "NATIVE_RESPONSE_2" in ui.painted(view.window), + seconds=20, + why="the session after clicking its tab", + ) assert ui.conversation is view assert ui.draft() == "PRESERVED_UNSENT_DRAFT" assert editor.document is document and editor.history is undo assert comms.registry.require("beta").process_identity == owner assert len(provider.requests) == 2 await agent.session.reconnect() - await ui.until(agent.session.settled.is_set, seconds=30, why="the reconnect to settle") - await ui.until(lambda: ui.agent_ready() and "NATIVE_RESPONSE_2" in ui.painted(view.window), seconds=20, - why="history after reattaching") + await ui.until( + agent.session.settled.is_set, seconds=30, why="the reconnect to settle" + ) + await ui.until( + lambda: ui.agent_ready() and "NATIVE_RESPONSE_2" in ui.painted(view.window), + seconds=20, + why="history after reattaching", + ) assert agent.configuration.thinking.current == "high" - assert len(provider.requests) == 2 and not view.submissions.queue_projection.items + assert ( + len(provider.requests) == 2 and not view.submissions.queue_projection.items + ) diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index 58aa5a22b..7ab44cb9f 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -18,21 +18,33 @@ def rows_with(chat, predicate): - return [fragment.message for fragment in chat.message_history.fragments_in_range - if predicate(fragment.message)] + return [ + fragment.message + for fragment in chat.message_history.fragments_in_range + if predicate(fragment.message) + ] def feedback_of(chat, sent) -> NotificationStatus: """The notification status drawn under the one message showing `sent`.""" - shown = [fragment.shown.status for fragment in chat.message_history.fragments_in_range - if fragment.message.reference == sent.reference] + shown = [ + fragment.shown.status + for fragment in chat.message_history.fragments_in_range + if fragment.message.reference == sent.reference + ] assert len(shown) == 1, (sent.reference, len(shown)) return shown[0] -async def direct_reply_feedback(native, owner_tab: str, *, hold: bool, target: str = "beta", - body: str = "DIRECT_NATIVE_MESSAGE", - reply_prefix: str | None = "NATIVE_RESPONSE_") -> None: +async def direct_reply_feedback( + native, + owner_tab: str, + *, + hold: bool, + target: str = "beta", + body: str = "DIRECT_NATIVE_MESSAGE", + reply_prefix: str | None = "NATIVE_RESPONSE_", +) -> None: """Send one direct message and follow it: responding while open, settled while hidden, answered exactly once on return, and once again after closing and reopening the tab. @@ -52,46 +64,75 @@ async def direct_reply_feedback(native, owner_tab: str, *, hold: bool, target: s provider.hold_next_request() await chat.submit_input(input_events.UserInputSubmitted(body)) if hold: - await ui.until(provider.entered.is_set, seconds=30, why="the direct message at the provider") - await ui.until(lambda: rows_with(chat, lambda message: message.body == body), seconds=20, - why="the sent message in the chat") + await ui.until( + provider.entered.is_set, + seconds=30, + why="the direct message at the provider", + ) + await ui.until( + lambda: rows_with(chat, lambda message: message.body == body), + seconds=20, + why="the sent message in the chat", + ) sent_rows = rows_with(chat, lambda message: message.body == body) assert len(sent_rows) == 1 sent = sent_rows[0] assert sent.sender == user and sent.target == target - await ui.until(lambda: "Responding" in str(feedback_of(chat, sent).title), seconds=20, - why="'Responding' under the sent message") + await ui.until( + lambda: "Responding" in str(feedback_of(chat, sent).title), + seconds=20, + why="'Responding' under the sent message", + ) assert comms.registry.require(target).executing await ui.select_tab(owner_tab) assert app.selected_session is not screen provider.release() - await ui.until(lambda: not comms.registry.require(target).executing, seconds=20, - why="the reply turn to finish while the chat is hidden") + await ui.until( + lambda: not comms.registry.require(target).executing, + seconds=20, + why="the reply turn to finish while the chat is hidden", + ) assert app.selected_session is not screen def replies(): - return rows_with(chat, lambda message: message.sender == target and message.seq > sent.seq - and (reply_prefix is None or message.body.startswith(reply_prefix))) + return rows_with( + chat, + lambda message: message.sender == target + and message.seq > sent.seq + and (reply_prefix is None or message.body.startswith(reply_prefix)), + ) await DirectTarget(target).open(navigation) - assert app.selected_session is screen, "Returning replaced the open direct-message tab" + assert ( + app.selected_session is screen + ), "Returning replaced the open direct-message tab" await ui.until(replies, seconds=20, why="the reply in the chat") assert len(replies()) == 1 reply = replies()[0] assert reply.target == user and reply.reference != sent.reference - await ui.until(lambda: "Responded" in str(feedback_of(chat, sent).title), seconds=20, - why="'Responded' under the sent message") + await ui.until( + lambda: "Responded" in str(feedback_of(chat, sent).title), + seconds=20, + why="'Responded' under the sent message", + ) outcomes = await asyncio.to_thread(comms.views.message_notifications, (sent,)) - assert any(item.recipient == target and item.state == "Responded" - for item in outcomes[sent.seq, sent.message_id]) + assert any( + item.recipient == target and item.state == "Responded" + for item in outcomes[sent.seq, sent.message_id] + ) if not hold: from agent_comms.input_origin import WireInputOrigin recipient = comms.registry.require(target) - native_input = InputDispositions(comms.root / InputDispositions.filename).read().lookup( - InputDispositions.bus_key(sent, recipient)) - assert native_input.has_started and native_input.matches_owner(recipient.incarnation) + native_input = ( + InputDispositions(comms.root / InputDispositions.filename) + .read() + .lookup(InputDispositions.bus_key(sent, recipient)) + ) + assert native_input.has_started and native_input.matches_owner( + recipient.incarnation + ) assert isinstance(native_input.origin, WireInputOrigin) assert native_input.origin.reference == sent.reference @@ -102,13 +143,23 @@ def replies(): await reopened.wait_content_ready() fresh = reopened.query_one(CommsChatView) assert fresh.conversation == DmConversation(ThreadTarget(target)) - await ui.until(lambda: all( - sum(fragment.message.reference == reference - for fragment in fresh.message_history.fragments_in_range) == 1 - for reference in (sent.reference, reply.reference)), seconds=20, - why="the message and its reply once each after reopening") - await ui.until(lambda: "Responded" in str(feedback_of(fresh, sent).title), seconds=20, - why="'Responded' after reopening") + await ui.until( + lambda: all( + sum( + fragment.message.reference == reference + for fragment in fresh.message_history.fragments_in_range + ) + == 1 + for reference in (sent.reference, reply.reference) + ), + seconds=20, + why="the message and its reply once each after reopening", + ) + await ui.until( + lambda: "Responded" in str(feedback_of(fresh, sent).title), + seconds=20, + why="'Responded' after reopening", + ) await ui.select_tab(owner_tab) @@ -118,30 +169,55 @@ async def channel_message_feedback(native, owner_tab: str) -> None: ui, comms, provider = native.ui, native.comms, native.provider app, _pilot = ui.textual user = comms.messaging.user_identity(str(native.project)).name - await channel_target("#team").open(NavigationContext(app, owner_tab, native.project, user)) + await channel_target("#team").open( + NavigationContext(app, owner_tab, native.project, user) + ) await app.selected_session.wait_content_ready() chat = app.selected_session.query_one(CommsChatView) assert chat.conversation == ChannelConversation(ChannelTarget("#team")) provider.hold_next_request() await chat.submit_input(input_events.UserInputSubmitted("CHANNEL_NATIVE_TRIAGE")) - await ui.until(provider.entered.is_set, seconds=30, why="the triage question at the provider") - await ui.until(lambda: rows_with(chat, lambda message: message.body == "CHANNEL_NATIVE_TRIAGE"), - seconds=20, why="the sent message in the channel") + await ui.until( + provider.entered.is_set, seconds=30, why="the triage question at the provider" + ) + await ui.until( + lambda: rows_with( + chat, lambda message: message.body == "CHANNEL_NATIVE_TRIAGE" + ), + seconds=20, + why="the sent message in the channel", + ) sent_rows = rows_with(chat, lambda message: message.body == "CHANNEL_NATIVE_TRIAGE") assert len(sent_rows) == 1 sent = sent_rows[0] - await ui.until(lambda: comms.registry.require("beta").executing, seconds=20, why="the thread to be deciding") + await ui.until( + lambda: comms.registry.require("beta").executing, + seconds=20, + why="the thread to be deciding", + ) await refresh_chat(chat) await ui.step() provider.release() - await ui.until(lambda: not comms.registry.require("beta").executing, seconds=20, why="the decision to finish") + await ui.until( + lambda: not comms.registry.require("beta").executing, + seconds=20, + why="the decision to finish", + ) await refresh_chat(chat) - await ui.until(lambda: "Checked" in feedback_of(chat, sent).title, seconds=10, why="'Checked' under the message") - assert "no response" in feedback_of(chat, sent).title.lower(), feedback_of(chat, sent).title + await ui.until( + lambda: "Checked" in feedback_of(chat, sent).title, + seconds=10, + why="'Checked' under the message", + ) + assert "no response" in feedback_of(chat, sent).title.lower(), feedback_of( + chat, sent + ).title @asynccontextmanager -async def goal_on_real_owner(workspace, package, objective: str, *, size=(110, 35), arrange=None): +async def goal_on_real_owner( + workspace, package, objective: str, *, size=(110, 35), arrange=None +): """A real owner (Core's ACP server in this process, no model) with an active goal, shown in Toad through an agent object bound to the owner's session. diff --git a/tests/support/ui.py b/tests/support/ui.py index f7da1154c..33446c8f2 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -103,11 +103,15 @@ async def new_session(self) -> str: """Open another session tab on the project and return its identity.""" @abstractmethod - async def open_channel(self, target: str, *, actor: str, owner: str | None = None) -> str: + async def open_channel( + self, target: str, *, actor: str, owner: str | None = None + ) -> str: """Open a channel or direct-message tab for `actor` and return its identity.""" @abstractmethod - async def open_direct(self, thread: str, *, actor: str, owner: str | None = None) -> str: + async def open_direct( + self, thread: str, *, actor: str, owner: str | None = None + ) -> str: """Open the direct-message history between `actor` and `thread` and return its tab identity.""" @abstractmethod @@ -164,7 +168,9 @@ def shows(self, text: str) -> bool: async def type(self, text: str) -> None: await self.press(*("space" if char == " " else char for char in text)) - async def until(self, condition: Callable[[], object], *, seconds: float = 10, why: str = "") -> None: + async def until( + self, condition: Callable[[], object], *, seconds: float = 10, why: str = "" + ) -> None: """Wait until `condition()` is true (it may be a coroutine function).""" try: async with asyncio.timeout(seconds): @@ -176,13 +182,19 @@ async def until(self, condition: Callable[[], object], *, seconds: float = 10, w return await self.step(0.02) except TimeoutError: - raise NotShown(f"{why or 'condition'} not reached in {seconds}s; screen:\n{self.visible_text()}") from None + raise NotShown( + f"{why or 'condition'} not reached in {seconds}s; screen:\n{self.visible_text()}" + ) from None async def until_agent_ready(self, *, seconds: float = 30) -> None: - await self.until(self.agent_ready, seconds=seconds, why="the agent to accept input") + await self.until( + self.agent_ready, seconds=seconds, why="the agent to accept input" + ) async def until_shows(self, text: str, *, seconds: float = 10) -> None: - await self.until(lambda: self.shows(text), seconds=seconds, why=f"{text!r} on screen") + await self.until( + lambda: self.shows(text), seconds=seconds, why=f"{text!r} on screen" + ) class TextualUi(AppAgents, Ui): @@ -203,8 +215,10 @@ def visible_lines(self) -> list[str]: def tabs(self) -> tuple[Tab, ...]: from toad.widgets.session_tabs import SessionLabel, SessionsTabs - return tuple(Tab(label.id, label.render().plain) - for label in self.app.screen.query_one(SessionsTabs).query(SessionLabel)) + return tuple( + Tab(label.id, label.render().plain) + for label in self.app.screen.query_one(SessionsTabs).query(SessionLabel) + ) @property def selected_tab(self) -> str: @@ -224,8 +238,12 @@ def conversation(self): def painted(self, widget) -> str: """The text drawn inside one widget's region of the screen.""" region = widget.region - return "\n".join(strip.crop(region.x, region.right).text - for strip in self.app.screen._compositor.render_strips()[region.y:region.bottom]) + return "\n".join( + strip.crop(region.x, region.right).text + for strip in self.app.screen._compositor.render_strips()[ + region.y : region.bottom + ] + ) @property def _prompt(self): @@ -259,18 +277,25 @@ def _cell_of(self, text: str, occurrence: int) -> tuple[int, int]: """The screen cell where the nth drawn `text` starts.""" from rich.cells import cell_len - places = [(cell_len(line[:line.find(text)]), y) - for y, line in enumerate(self.visible_lines()) if text in line] + places = [ + (cell_len(line[: line.find(text)]), y) + for y, line in enumerate(self.visible_lines()) + if text in line + ] if len(places) <= occurrence: raise NotShown(f"{text!r} is not on screen:\n{self.visible_text()}") return places[occurrence] async def click(self, text: str, *, occurrence: int = 0) -> None: - assert await self.pilot.click(offset=self._cell_of(text, occurrence)), f"the click on {text!r} left the screen" + assert await self.pilot.click( + offset=self._cell_of(text, occurrence) + ), f"the click on {text!r} left the screen" await self.step() def link_at(self, text: str, *, occurrence: int = 0) -> tuple[str, tuple] | None: - return self.app.screen.get_style_at(*self._cell_of(text, occurrence)).meta.get("@click") + return self.app.screen.get_style_at(*self._cell_of(text, occurrence)).meta.get( + "@click" + ) def hyperlink_at(self, text: str, *, occurrence: int = 0) -> str | None: return self.app.screen.get_style_at(*self._cell_of(text, occurrence)).link @@ -291,22 +316,32 @@ async def new_session(self) -> str: from toad.screens.main import MainScreen app = self.app - details = await app.session_navigation.new(lambda: MainScreen(app.project_dir, app.agent_data)) + details = await app.session_navigation.new( + lambda: MainScreen(app.project_dir, app.agent_data) + ) await self.step() return details.mode_name - async def open_channel(self, target: str, *, actor: str, owner: str | None = None) -> str: + async def open_channel( + self, target: str, *, actor: str, owner: str | None = None + ) -> str: from toad.navigation_target import channel_target - context = NavigationContext(self.app, owner or self.selected_tab, self.app.project_dir, actor) + context = NavigationContext( + self.app, owner or self.selected_tab, self.app.project_dir, actor + ) identity = await channel_target(target).open(context) await self.step() return identity - async def open_direct(self, thread: str, *, actor: str, owner: str | None = None) -> str: + async def open_direct( + self, thread: str, *, actor: str, owner: str | None = None + ) -> str: from toad.navigation_target import DirectTarget - context = NavigationContext(self.app, owner or self.selected_tab, self.app.project_dir, actor) + context = NavigationContext( + self.app, owner or self.selected_tab, self.app.project_dir, actor + ) identity = await DirectTarget(thread).open(context) await self.step() return identity diff --git a/tests/textual/test_block_menu_and_copy.py b/tests/textual/test_block_menu_and_copy.py index c27191f69..3bd61e832 100644 --- a/tests/textual/test_block_menu_and_copy.py +++ b/tests/textual/test_block_menu_and_copy.py @@ -34,8 +34,20 @@ def private_display(private_display_programs, monkeypatch): xvfb, xclip = private_display_programs read_fd, write_fd = os.pipe() server = subprocess.Popen( - [xvfb, "-displayfd", str(write_fd), "-screen", "0", "1100x740x24", "-nolisten", "tcp"], - pass_fds=(write_fd,), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + [ + xvfb, + "-displayfd", + str(write_fd), + "-screen", + "0", + "1100x740x24", + "-nolisten", + "tcp", + ], + pass_fds=(write_fd,), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) os.close(write_fd) try: assert select.select([read_fd], [], [], 5)[0], "Private X11 did not start" @@ -59,9 +71,27 @@ async def test_block_menu_copy_prompt_and_expansion(workspace, private_display): ) user = UserInput("USER_COPY_PAINT") extra = DeclaredExtraBlock("DECLARED_NEW_PAINT") - tool = ToolCall(ToolCallStatus.from_acp(SDKToolCall.model_validate({ - "toolCallId": "t4-expansion", "title": "Actual expansion", "kind": "other", "status": "in_progress", - "content": [{"type": "content", "content": {"type": "text", "text": "TOOL_EXPANSION_PAINT"}}]}))) + tool = ToolCall( + ToolCallStatus.from_acp( + SDKToolCall.model_validate( + { + "toolCallId": "t4-expansion", + "title": "Actual expansion", + "kind": "other", + "status": "in_progress", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_EXPANSION_PAINT", + }, + } + ], + } + ) + ) + ) tool.set_expanded(False) await view.contents.mount(user, extra, tool) await ui.step() @@ -81,8 +111,12 @@ async def choose(widget, key): async def copied(widget, text): await choose(widget, "c") assert app.clipboard == text - actual = subprocess.run([xclip, "-selection", "clipboard", "-o"], - capture_output=True, timeout=3, check=True) + actual = subprocess.run( + [xclip, "-selection", "clipboard", "-o"], + capture_output=True, + timeout=3, + check=True, + ) assert actual.stdout.decode() == text await copied(user, "USER_COPY_PAINT") @@ -99,7 +133,10 @@ async def copied(widget, text): assert view.check_action("expand_block", ()) is True assert view.check_action("collapse_block", ()) is False await ui.press("space") - await ui.until(lambda: tool.expanded and ui.shows("TOOL_EXPANSION_PAINT"), why="the tool call to expand") + await ui.until( + lambda: tool.expanded and ui.shows("TOOL_EXPANSION_PAINT"), + why="the tool call to expand", + ) assert view.check_action("expand_block", ()) is False assert view.check_action("collapse_block", ()) is True await ui.press("space") diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 17cbd29e1..3799430fa 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -10,7 +10,9 @@ from toad.widgets.channel_activity import ChannelActivity -async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and_return(workspace): +async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and_return( + workspace, +): workspace.thread("peer", "team", title="Conversation peer") viewer = workspace.viewer workspace.comms.messaging.send("peer", "#team", "CHANNEL_VALUE_HISTORY") @@ -20,36 +22,62 @@ async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and owner = ui.selected_tab context = NavigationContext(app, owner, workspace.project, viewer) for target, expected, body, status_name, destination in ( - (channel_target("#team"), ChannelConversation(ChannelTarget("#team")), - "CHANNEL_VALUE_HISTORY", "", "#team"), - (DirectTarget("peer"), DmConversation(ThreadTarget("peer")), - "DM_VALUE_HISTORY", "peer", "peer"), - (FeedTarget(), IrcConversation(ChannelTarget(ALL_COMMS_TARGET)), - "CHANNEL_VALUE_HISTORY", "", AllChannel.declared_name), + ( + channel_target("#team"), + ChannelConversation(ChannelTarget("#team")), + "CHANNEL_VALUE_HISTORY", + "", + "#team", + ), + ( + DirectTarget("peer"), + DmConversation(ThreadTarget("peer")), + "DM_VALUE_HISTORY", + "peer", + "peer", + ), + ( + FeedTarget(), + IrcConversation(ChannelTarget(ALL_COMMS_TARGET)), + "CHANNEL_VALUE_HISTORY", + "", + AllChannel.declared_name, + ), ): mode = await target.open(context) screen = app.selected_session assert screen.navigation_target == target await screen.wait_content_ready() chat = screen.query_one(CommsChatView) - await ui.until(lambda: body in ui.painted(chat.window), - why="the conversation's saved message on screen") + await ui.until( + lambda: body in ui.painted(chat.window), + why="the conversation's saved message on screen", + ) entry = app.session_navigation.get(mode) conversation = entry.key.conversation assert conversation == expected assert screen.conversation is chat.conversation is conversation assert chat.message_history.page_source.loader.conversation is conversation - assert next(tab.title for tab in ui.tabs() if tab.identity == mode).startswith(expected.label()) - status = chat.message_history.page_source.status_thread(app.coordination_access) + assert next( + tab.title for tab in ui.tabs() if tab.identity == mode + ).startswith(expected.label()) + status = chat.message_history.page_source.status_thread( + app.coordination_access + ) assert status.name == status_name outgoing = f"{workspace.roots.attempt.name}: P390_SENT_TO_{destination}" await ui.write_draft(outgoing) await ui.press("enter") - await ui.until(lambda: outgoing in ui.painted(chat.window), - why="the sent message returning through Core history") - sent, = (fragment.message for fragment in chat.message_history.fragments_in_range - if fragment.message.body == outgoing) + await ui.until( + lambda: outgoing in ui.painted(chat.window), + why="the sent message returning through Core history", + ) + (sent,) = ( + fragment.message + for fragment in chat.message_history.fragments_in_range + if fragment.message.body == outgoing + ) assert (sent.sender, sent.target) == (viewer, destination) chat.prompt.text = "retained draft" @@ -62,6 +90,10 @@ async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and await channel_target("#team").open(context) chat = app.selected_session.query_one(CommsChatView) - workspace.thread("late-peer", "team", running=True, title="Late conversation peer") - await ui.until(lambda: "late-peer" in chat.query_one(ChannelActivity).rows, - why="the new member in the channel's activity rows") + workspace.thread( + "late-peer", "team", running=True, title="Late conversation peer" + ) + await ui.until( + lambda: "late-peer" in chat.query_one(ChannelActivity).rows, + why="the new member in the channel's activity rows", + ) diff --git a/tests/textual/test_navigation_route_reads.py b/tests/textual/test_navigation_route_reads.py index 6bfc9479d..08ffa5a69 100644 --- a/tests/textual/test_navigation_route_reads.py +++ b/tests/textual/test_navigation_route_reads.py @@ -26,7 +26,9 @@ def read(self, request): return self.original(request) def holding(self): - return patch.object(self.request_type, "read", lambda request: self.read(request)) + return patch.object( + self.request_type, "read", lambda request: self.read(request) + ) async def opening_while_the_user_types(workspace, request_type, target): @@ -44,7 +46,10 @@ async def opening_while_the_user_types(workspace, request_type, target): with gate.holding(): opening = asyncio.create_task(target.open(context)) assert await asyncio.to_thread(gate.entered.wait, 2) - assert gate.thread_ids == [gate.thread_ids[0]] and gate.thread_ids[0] != threading.get_ident() + assert ( + gate.thread_ids == [gate.thread_ids[0]] + and gate.thread_ids[0] != threading.get_ident() + ) assert ui.selected_tab == owner await ui.write_draft("") await ui.type("keep") @@ -62,15 +67,25 @@ async def opening_while_the_user_types(workspace, request_type, target): await asyncio.gather(opening, return_exceptions=True) -async def test_a_thread_opening_reads_off_the_ui_thread_and_a_later_tab_choice_wins(workspace): - await opening_while_the_user_types(workspace, ThreadNavigationRequest, ThreadTarget("metadata-peer")) +async def test_a_thread_opening_reads_off_the_ui_thread_and_a_later_tab_choice_wins( + workspace, +): + await opening_while_the_user_types( + workspace, ThreadNavigationRequest, ThreadTarget("metadata-peer") + ) -async def test_a_channel_opening_reads_off_the_ui_thread_and_a_later_tab_choice_wins(workspace): - await opening_while_the_user_types(workspace, CommsNavigationRequest, channel_target("#all")) +async def test_a_channel_opening_reads_off_the_ui_thread_and_a_later_tab_choice_wins( + workspace, +): + await opening_while_the_user_types( + workspace, CommsNavigationRequest, channel_target("#all") + ) -async def test_a_renamed_thread_reuses_its_open_tab_before_the_rename_reaches_the_view(workspace): +async def test_a_renamed_thread_reuses_its_open_tab_before_the_rename_reaches_the_view( + workspace, +): workspace.thread("metadata-peer", running=True) async with workspace.app(size=(100, 32)) as ui: app, _ = ui.textual @@ -81,11 +96,14 @@ async def test_a_renamed_thread_reuses_its_open_tab_before_the_rename_reaches_th existing._comms_thread = "metadata-peer" workspace.comms.registry.rename("metadata-peer", "metadata-renamed") reused = await app.thread_navigation.open( - owner_mode=owner, project_path=workspace.project, target="metadata-peer") + owner_mode=owner, project_path=workspace.project, target="metadata-peer" + ) assert reused == other and app.session_tracker.session_count == 2 -async def test_a_channel_read_finishing_after_its_owner_closed_opens_in_the_remaining_tab(workspace): +async def test_a_channel_read_finishing_after_its_owner_closed_opens_in_the_remaining_tab( + workspace, +): workspace.thread("metadata-peer", running=True) async with workspace.app(size=(100, 32)) as ui: app, _ = ui.textual @@ -97,13 +115,21 @@ async def test_a_channel_read_finishing_after_its_owner_closed_opens_in_the_rema opening = None try: with gate.holding(): - opening = asyncio.create_task(channel_target("#all").open( - NavigationContext(app, owner, workspace.project, source._comms_thread))) + opening = asyncio.create_task( + channel_target("#all").open( + NavigationContext( + app, owner, workspace.project, source._comms_thread + ) + ) + ) assert await asyncio.to_thread(gate.entered.wait, 2) await asyncio.wait_for(ui.close_tab(owner), 2) gate.release.set() assert await asyncio.wait_for(opening, 2) == other - assert owner not in [tab.identity for tab in ui.tabs()] and len(ui.tabs()) == 1 + assert ( + owner not in [tab.identity for tab in ui.tabs()] + and len(ui.tabs()) == 1 + ) finally: gate.release.set() if opening is not None: diff --git a/tests/textual/test_sidebar_destinations.py b/tests/textual/test_sidebar_destinations.py index b2aaaa221..159b07977 100644 --- a/tests/textual/test_sidebar_destinations.py +++ b/tests/textual/test_sidebar_destinations.py @@ -19,7 +19,9 @@ async def open(self, context): if sidebar.collapsed: sidebar.toggle() tree = sidebar.query_one(CommsSidebar) - await ui.until(lambda: "#team" in tree.groups, why="the real sidebar's channel group") + await ui.until( + lambda: "#team" in tree.groups, why="the real sidebar's channel group" + ) group = tree.groups["#team"] absent = app.workspace_sessions.visible_target(None) assert not absent.matches(app.selected_session.navigation_target) @@ -32,5 +34,7 @@ async def open(self, context): await ui.step() context = app.selected_session.navigation_context assert await pilot.click(row) - await ui.until(lambda: bool(opened), why="the row opening its declared destination") + await ui.until( + lambda: bool(opened), why="the row opening its declared destination" + ) assert opened == [context] diff --git a/tests/textual/test_tab_underline.py b/tests/textual/test_tab_underline.py index e65551182..cf4bbcf04 100644 --- a/tests/textual/test_tab_underline.py +++ b/tests/textual/test_tab_underline.py @@ -14,7 +14,11 @@ def highlighted_columns(ui) -> list[int]: color = underline.get_component_rich_style("underline--bar").color columns, x = [], 0 for segment in app.screen._compositor.render_strips()[underline.region.y]: - if segment.style is not None and segment.style.color == color and "━" in segment.text: + if ( + segment.style is not None + and segment.style.color == color + and "━" in segment.text + ): columns.extend(range(x, x + segment.cell_length)) x += segment.cell_length return columns @@ -22,7 +26,11 @@ def highlighted_columns(ui) -> list[int]: def label_columns(ui, identity) -> range: app, _ = ui.textual - region = app.screen.query_one(SessionsTabs).query_one(f"#{identity}", SessionLabel).region + region = ( + app.screen.query_one(SessionsTabs) + .query_one(f"#{identity}", SessionLabel) + .region + ) return range(region.x, region.right) @@ -31,22 +39,45 @@ def under(ui, identity) -> bool: return bool(columns) and all(column in label for column in columns) -@pytest.mark.parametrize("target", [DirectTarget("alpha"), channel_target("#team")], ids=["direct", "channel"]) -async def test_underline_moves_to_a_tab_opened_from_the_sidebar_and_back(workspace, target): +@pytest.mark.parametrize( + "target", + [DirectTarget("alpha"), channel_target("#team")], + ids=["direct", "channel"], +) +async def test_underline_moves_to_a_tab_opened_from_the_sidebar_and_back( + workspace, target +): workspace.thread("owner", "team") workspace.thread("alpha", "team") async with workspace.app(size=(140, 32)) as ui: app, _ = ui.textual first = ui.selected_tab - await ui.until(lambda: under(ui, first), why="the underline under the first tab") + await ui.until( + lambda: under(ui, first), why="the underline under the first tab" + ) - opened = await target.open(NavigationContext(app, first, app.project_dir, "owner")) - await ui.until(lambda: ui.selected_tab == opened != first, why="the opened tab selected") - await ui.until(lambda: under(ui, opened), seconds=3, - why=f"the underline under the opened tab; it is painted at {highlighted_columns(ui)[:1]}, " - f"the tab is at {label_columns(ui, opened)}") + opened = await target.open( + NavigationContext(app, first, app.project_dir, "owner") + ) + await ui.until( + lambda: ui.selected_tab == opened != first, why="the opened tab selected" + ) + await ui.until( + lambda: under(ui, opened), + seconds=3, + why=f"the underline under the opened tab; it is painted at {highlighted_columns(ui)[:1]}, " + f"the tab is at {label_columns(ui, opened)}", + ) await ui.select_tab(first) - await ui.until(lambda: under(ui, first), seconds=3, why="the underline back under the first tab") + await ui.until( + lambda: under(ui, first), + seconds=3, + why="the underline back under the first tab", + ) await ui.close_tab(opened) - await ui.until(lambda: under(ui, first), seconds=3, why="the underline under the remaining tab") + await ui.until( + lambda: under(ui, first), + seconds=3, + why="the underline under the remaining tab", + ) From 4218fc8adacd2058467ace8e31803476bf3e2ebf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 08:22:47 -0400 Subject: [PATCH 12/43] P401: derive native application contracts and pin merged ancestor support --- src/toad/app_node.py | 16 +++++++ src/toad/screens/main.py | 50 ++++++++++---------- src/toad/screens/workspace.py | 19 +++----- src/toad/session_scope.py | 7 ++- src/toad/status_thread.py | 3 -- src/toad/widgets/observed_thread_activity.py | 7 ++- src/toad/workspace_sessions.py | 27 +++++++---- uv.lock | 2 +- 8 files changed, 74 insertions(+), 57 deletions(-) create mode 100644 src/toad/app_node.py diff --git a/src/toad/app_node.py b/src/toad/app_node.py new file mode 100644 index 000000000..841b521de --- /dev/null +++ b/src/toad/app_node.py @@ -0,0 +1,16 @@ +"""Native nodes bound to Toad's application contract.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.dom import DOMNode + +if TYPE_CHECKING: + from toad.app import ToadApp + + +class ToadNode(DOMNode): + """Keep the application refinement on one shared native parent.""" + + app: ToadApp diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index aa09eb404..6dea6aeab 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -1,27 +1,20 @@ -from toad.core import input_events -from toad.core_event_carrier import CoreEventMessage -from toad.core import session_requests, events as core_events -from toad.core.events import SessionChangedEvent -from toad.format_path import format_path import asyncio from abc import abstractmethod from collections.abc import Iterable -from acp.exceptions import RequestError from functools import partial from pathlib import Path from typing import cast +from acp.exceptions import RequestError from agent_comms.acp_extension import CoordinationChangedUpdate from agent_comms.coordination_errors import DMScopeChanged -from agent_comms.route_selection import RouteSelection from agent_comms.declared_family import DeclaredFamily from agent_comms.mro_dispatch import handles +from agent_comms.route_selection import RouteSelection from textual import containers, getters, on from textual.app import ComposeResult from textual.binding import Binding from textual.command import DiscoveryHit, Hit, Hits, Provider, SimpleCommand -from textual.content import Content -from textual.events import ScreenResume from textual.reactive import var from textual.widget import Widget from textual.widgets import ( @@ -31,34 +24,36 @@ Tree, ) -from toad import messages - from toad.acp.agent_session import SessionDisposition from toad.agent_schema import AgentDefinition from toad.app import ToadApp +from toad.core import events as core_events +from toad.core import input_events, session_requests +from toad.core.events import SessionChangedEvent +from toad.core.input_events import SelectTarget +from toad.core_event_carrier import CoreEventMessage +from toad.format_path import format_path from toad.navigation_context import NavigationContext -from toad.screens.session_view import SessionView -from toad.session_scope import ViewSessionOrigin -from toad.widgets.message_notifications import NothingToRead, PaintedRead, ReadWork +from toad.navigation_target import ( + DirectTarget, + FeedTarget, + NavigationTarget, + ThreadTarget, +) from toad.screens.mcp_inventory import MCPInventoryScreen +from toad.screens.session_view import SessionView from toad.session_presentation import OperationalSessionPresentation +from toad.session_scope import ViewSessionOrigin from toad.session_tracker import SidebarState from toad.thread_identity import SessionIdentity, ThreadIdentity -from toad.core.input_events import SelectTarget -from toad.widgets.comms_sidebar import CommsSidebar +from toad.widgets.channels_sidebar import ChannelsSidebar from toad.widgets.conversation import Conversation -from toad.widgets.splash_text import ThreadLoading -from toad.widgets.session_thread_sidebar import SessionThreadSidebar +from toad.widgets.message_notifications import NothingToRead, PaintedRead, ReadWork from toad.widgets.project_directory_tree import ProjectDirectoryTree from toad.widgets.project_panel import ProjectPanel, ProjectSearchButton -from toad.widgets.side_bar import SideBar, SideBarCollapsible -from toad.widgets.channels_sidebar import ChannelsSidebar -from toad.navigation_target import ( - FeedTarget, - DirectTarget, - NavigationTarget, - ThreadTarget, -) +from toad.widgets.session_thread_sidebar import SessionThreadSidebar +from toad.widgets.side_bar import SideBar +from toad.widgets.splash_text import ThreadLoading class SessionCommands(Provider): @@ -373,6 +368,9 @@ def session_disposition(self) -> SessionDisposition: return self.presentation.operational_binding.session_disposition def make_conversation(self) -> Conversation: + assert ( + self.id is not None + ), "Conversation construction requires a registered session" return Conversation( self.project_path, self._agent, diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index cfc984350..157176ede 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -2,24 +2,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING - +from abc import abstractmethod from dataclasses import dataclass, replace from functools import cached_property +from agent_comms.declared_family import DeclaredFamily from textual.binding import Binding +from textual.box_model import DEFAULT_LAYER from textual.events import ScreenResume from textual.geometry import Size from textual.screen import Screen from textual.widget import Widget -from textual.box_model import DEFAULT_LAYER -from toad.widget_view import WidgetView -from agent_comms.declared_family import DeclaredFamily -from abc import abstractmethod - -if TYPE_CHECKING: - from toad.app import ToadApp +from toad.app_node import ToadNode +from toad.widget_view import WidgetView @dataclass(frozen=True) @@ -77,8 +73,7 @@ def reusable(self, screen, size, *, include_scroll=True): ) -class WorkspaceScreen(WidgetView, Screen): - app: ToadApp +class WorkspaceScreen(WidgetView, ToadNode, Screen): layer_order = (DEFAULT_LAYER, "comms-sidebar", "_tooltips", "_toastrack") BINDINGS = [ @@ -106,7 +101,7 @@ def coordination_root(self): CSS_PATH = ["main.tcss", "comms.tcss"] def compose(self): - from textual.containers import Horizontal, Container + from textual.containers import Container, Horizontal chrome = self.app.workspace_chrome yield chrome.navigation diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 94f2ba7c9..e535c0a5a 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -14,8 +14,8 @@ from agent_comms.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.observation import ReadTranscript, ReadTranscriptUpdate -from textual.dom import DOMNode +from toad.app_node import ToadNode from toad.core.events import ThreadRowsChanged from toad.core_event_carrier import ThreadCallbackSubscription from toad.navigation_context import NavigationOwner @@ -25,9 +25,9 @@ if TYPE_CHECKING: from textual.command import Provider + from textual.dom import DOMNode from toad.agent_schema import AgentDefinition - from toad.app import ToadApp from toad.navigation_target import NavigationTarget from toad.session_presentation import OperationalSessionPresentation from toad.thread_identity import SessionIdentity @@ -172,10 +172,9 @@ def resolve_thread(self) -> Never: self.require() -class SessionScope(NavigationOwner, ProjectPathOwner, DOMNode): +class SessionScope(NavigationOwner, ProjectPathOwner, ToadNode): """The session answers read by ancestors and workspace owners.""" - app: ToadApp COMMANDS: ClassVar[set[type[Provider]]] = set() AUTO_FOCUS = "" shows_channels = True diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py index 2c517319d..87ee72334 100644 --- a/src/toad/status_thread.py +++ b/src/toad/status_thread.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import TYPE_CHECKING -from agent_comms.coordination_errors import CoordinationReadUnavailable from agent_comms.ui_model.observation import UnreadableHistory from agent_comms.ui_model.status import ThreadStatusModel @@ -98,8 +97,6 @@ async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: ) ) row = ThreadStatusModel.known(self.name, presentation) - except CoordinationReadUnavailable: - return except (OSError, ValueError, RuntimeError) as error: presentation = None row = ThreadStatusModel.unreadable( diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index a85c243fc..974152987 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -3,11 +3,14 @@ from __future__ import annotations from dataclasses import dataclass + from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.status import ThreadStatusModel from textual.message import Message from textual.reactive import var from textual.widget import Widget + +from toad.app_node import ToadNode from toad.core.events import ThreadRowsChanged from toad.core_event_carrier import CoreEventReceiver from toad.status_thread import NoStatusThread, StatusThread @@ -42,7 +45,7 @@ def working(self) -> bool: return False -class ObservedThreadActivity(CoreEventReceiver, Widget, ActivityView): +class ObservedThreadActivity(CoreEventReceiver, ToadNode, Widget, ActivityView): """The composed status row; observation never starts or settles an ACP turn.""" row: var[ThreadStatusModel] = var(lambda: ThreadStatusModel.pending(""), init=False) @@ -82,7 +85,7 @@ def working(self) -> bool: return self.row.status.working -class ThreadStatusOwner: +class ThreadStatusOwner(ToadNode): """A view with a composed row and a declared status source.""" # Before compose there is no status view yet; the kind supplies its view. diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 7949f2af0..8389f0457 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -1,20 +1,21 @@ """Logical session membership; Textual owns only the persistent native frame.""" -from collections.abc import Callable from abc import abstractmethod +from collections.abc import Callable from typing import TYPE_CHECKING + +from agent_comms.declared_family import DeclaredFamily from textual._time import after_input from textual.containers import Container -from agent_comms.declared_family import DeclaredFamily from toad.constants import WELCOME +from toad.navigation_target import NavigationTarget, NoTarget from toad.session_scope import ( NoSessionOrigin, SessionOrigin, SessionScope, ViewSessionOrigin, ) -from toad.navigation_target import NavigationTarget, NoTarget if TYPE_CHECKING: from toad.app import ToadApp @@ -53,6 +54,9 @@ def coordination_root(self): ... @abstractmethod def focus_sidebar_return(self) -> None: ... + @abstractmethod + def visible_target(self, sessions: WorkspaceSessions, root) -> NavigationTarget: ... + @abstractmethod def sidebars(self): """Native sidebar membership belongs to this logical source.""" @@ -81,6 +85,9 @@ def coordination_root(self): def focus_sidebar_return(self) -> None: """A detached workspace has no return target.""" + def visible_target(self, sessions: WorkspaceSessions, root) -> NavigationTarget: + return NoTarget() + def sidebars(self): return () @@ -118,6 +125,13 @@ def coordination_root(self): def focus_sidebar_return(self) -> None: self.view.focus_sidebar_return() + def visible_target(self, sessions: WorkspaceSessions, root) -> NavigationTarget: + return ( + sessions.target_for_mode(self.identity, root) + if sessions.owns(self.view) + else NoTarget() + ) + def sidebars(self): return self.view.sidebars() @@ -218,12 +232,7 @@ def owns(self, view: "SessionScope") -> bool: def visible_target(self, root) -> NavigationTarget: """The selected logical destination, including while a modal covers its frame.""" - view = self.source.view - return ( - self.target_for_mode(view.id, root) - if view is not None and self.owns(view) - else NoTarget() - ) + return self.source.visible_target(self, root) def target_for_mode(self, identity, root) -> NavigationTarget: """Resolve a transition's destination from its original workspace view.""" diff --git a/uv.lock b/uv.lock index c5bd95b5f..66697be03 100644 --- a/uv.lock +++ b/uv.lock @@ -1603,7 +1603,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#b6cd52b7eff6016464c7a8dd95fecfba4b9ab3a3" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#cdddff395cefb49f3f47ac689af60bbb693b0c5a" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, From 476a74ba29ce9534e0774cd57d7a9914ec4d034a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 09:42:29 -0400 Subject: [PATCH 13/43] Own history registration, native launch state and foreign read outcomes --- src/toad/acp/comms_updates.py | 60 +++++++++---- src/toad/acp/transcript_reader.py | 20 +++-- src/toad/navigation_target.py | 53 ++++++----- src/toad/screens/main.py | 11 ++- src/toad/session_navigation.py | 37 ++++---- src/toad/session_scope.py | 121 ++++++++++++++++++++++---- src/toad/status_thread.py | 40 ++++++--- src/toad/widgets/comms_chat.py | 88 ++++++++----------- tests/app/test_agent_binding.py | 43 +++++++++ tests/app/test_foreign_status_read.py | 52 +++++++++++ tests/model/test_conversation_kind.py | 20 ++++- 11 files changed, 390 insertions(+), 155 deletions(-) create mode 100644 tests/app/test_agent_binding.py create mode 100644 tests/app/test_foreign_status_read.py diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 71b946336..26e356fed 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -14,8 +14,8 @@ InputDeliveryChangedUpdate, InputFailedUpdate, InputStartedUpdate, - PromptCancelledUpdate, McpClientAcknowledgementUpdate, + PromptCancelledUpdate, QueueChangedUpdate, RequestFailedUpdate, SelectedWriteAcceptedUpdate, @@ -27,9 +27,9 @@ from agent_comms.context_reading import UnmeasuredContext from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.thread_presentation import ThreadPresentation + from toad.acp.client_session import ClientSessionRequest from toad.acp.context_measurement import ContextMeasurement - from toad.core import events as core_events @@ -53,7 +53,9 @@ async def consume_metadata(self, metadata): from .sdk_boundary import DecodeCommsMetadataTask authority = ClientSessionRequest(self.agent, self.session_id) - facts = await self.agent.controller.task_runner.run(DecodeCommsMetadataTask(metadata)) + facts = await self.agent.controller.task_runner.run( + DecodeCommsMetadataTask(metadata) + ) authority.require() for fact in facts: self.dispatch_sync(fact) @@ -63,7 +65,7 @@ def accepts_turn(self): def require_compaction_update(self): if self.compaction_update is None: - raise ValueError('Compaction result was missing') + raise ValueError("Compaction result was missing") return self.compaction_update @handles(TextRouteUpdate) @@ -72,7 +74,11 @@ def text_route(self, update: TextRouteUpdate) -> None: @handles(InputFailedUpdate) def input_failed(self, update: InputFailedUpdate) -> None: - self.agent.events.publish(core_events.CommsUpdated(update, recover_draft=False, session_id=self.session_id)) + self.agent.events.publish( + core_events.CommsUpdated( + update, recover_draft=False, session_id=self.session_id + ) + ) @handles(TranscriptChangedUpdate) def transcript_changed(self, update: TranscriptChangedUpdate) -> None: @@ -86,7 +92,11 @@ def turn_changed(self, update: TurnChangedUpdate) -> None: binding = agent.presentation.managed_turns() if not binding.receive(update.state): return - agent.events.publish(core_events.CommsUpdated(update, session_id=self.session_id, sequence=binding.sequence)) + agent.events.publish( + core_events.CommsUpdated( + update, session_id=self.session_id, sequence=binding.sequence + ) + ) @handles(CursorAdvancedUpdate) def cursor_advanced(self, update: CursorAdvancedUpdate) -> None: @@ -113,7 +123,9 @@ def queue_changed(self, update: QueueChangedUpdate) -> None: def input_started(self, update: InputStartedUpdate) -> None: if update.scope.unidentified_start(update.input_id): if self.session_id == self.agent.session_id: - self.agent.events.publish(core_events.CommsUpdated(update, session_id=self.session_id)) + self.agent.events.publish( + core_events.CommsUpdated(update, session_id=self.session_id) + ) elif update.scope.matches_session(self.session_id): starts = self.agent.queue_attachment.started(update, self.session_id) self.agent._post_queue_view(starts) @@ -121,6 +133,7 @@ def input_started(self, update: InputStartedUpdate) -> None: @handles(CoordinationChangedUpdate) def coordination_changed(self, update: CoordinationChangedUpdate) -> None: from pathlib import Path + from agent_comms.route_selection import RouteChanged agent = self.agent @@ -131,7 +144,9 @@ def coordination_changed(self, update: CoordinationChangedUpdate) -> None: agent.project_root_path = Path(update.worktree) agent.context_measurement = ContextMeasurement.saved(update.context_usage) agent.update_status_line() - agent.events.publish(core_events.CommsUpdated(agent.coordination, self.session_id)) + agent.events.publish( + core_events.CommsUpdated(agent.coordination, self.session_id) + ) agent.session.coordinated_title(update.title) @handles(GoalChangedUpdate) @@ -151,12 +166,16 @@ def compaction_committed(self, update: CompactionCommittedUpdate) -> None: @handles(TranscriptSnapshotUpdate) def transcript_snapshot(self, update: TranscriptSnapshotUpdate) -> None: controller = self.agent.controller - controller.start_operation(controller.publish_transcript_snapshot( - update, controller.surface, controller.session)) + controller.start_operation( + controller.publish_transcript_snapshot( + update, controller.surface, controller.session + ) + ) @handles(InputDeliveryChangedUpdate) def input_delivery_changed(self, update: InputDeliveryChangedUpdate) -> None: from toad.core.events import InputDispositionsChanged + self.agent.events.publish(InputDispositionsChanged()) @handles(McpClientAcknowledgementUpdate) @@ -196,8 +215,9 @@ def __init__(self, agent, session_id, *, turn_token): self.turn_token = turn_token def accepts_turn(self): - return (super().accepts_turn() - and self.agent.controller.accepts_turn_snapshot(self.turn_token)) + return super().accepts_turn() and self.agent.controller.accepts_turn_snapshot( + self.turn_token + ) @handles(ThreadPresentation) def thread_presentation(self, presentation): @@ -210,9 +230,10 @@ def publish_thread_turn(self, root, thread): """Only the live transcript member can supply actual owner settlement.""" coordination = self.agent.coordination self.agent.controller.require_owner( - coordination, ClientSessionRequest(self.agent, self.session_id)) - if (root != coordination.wire_root or thread.incarnation != coordination.thread): - raise ValueError('Turn snapshot belongs to another original thread source') + coordination, ClientSessionRequest(self.agent, self.session_id) + ) + if root != coordination.wire_root or thread.incarnation != coordination.thread: + raise ValueError("Turn snapshot belongs to another original thread source") self.turn_changed(TurnChangedUpdate(thread.turn_state)) @@ -270,7 +291,8 @@ class PendingThreadRead(ThreadRead): def settle(self, presentation: ThreadPresentation | None) -> ThreadReadOutcome: current = self.agent.coordination if current is None or (current.wire_root, current.thread) != ( - self.coordination.wire_root, self.coordination.thread + self.coordination.wire_root, + self.coordination.thread, ): return ChangedAttachmentRead() if self.authority.retired: @@ -280,5 +302,7 @@ def settle(self, presentation: ThreadPresentation | None) -> ThreadReadOutcome: return SettledThreadRead(presentation) async def reconcile(self, transcripts) -> None: - self.settle(await transcripts.thread_presentation( - self.coordination.wire_root, self.coordination.thread.name)) \ No newline at end of file + read = await transcripts.thread_presentation( + self.coordination.wire_root, self.coordination.thread.name + ) + self.settle(read.raised()) diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index 5f92ba731..3808197a5 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -14,15 +14,20 @@ import asyncio from contextlib import asynccontextmanager +from functools import partial -from agent_comms.route_selection import RouteSelection from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate +from agent_comms.route_selection import RouteSelection +from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.observation import ( AwaitedRead, RefreshTranscript, + StoredRead, TranscriptPublicationState, + UnreadableHistory, ) +from toad.owner_preparation import read_thread_presentation from toad.session_scope import TranscriptScope @@ -43,12 +48,15 @@ async def service(self, root): lambda: self.access.require(RouteSelection.capture(root)).service ) - async def thread_presentation(self, root: str, thread: str): - """Read one thread presentation through this reader's bound service.""" - from toad.owner_preparation import read_thread_presentation - + async def thread_presentation( + self, root: str, thread: str + ) -> StoredRead[ThreadPresentation | None]: + """The stored-read owner classifies failures inside the read worker.""" async with self.bind(root) as reader: - return await asyncio.to_thread(read_thread_presentation, reader, thread) + return await asyncio.to_thread( + UnreadableHistory.outcome, + partial(read_thread_presentation, reader, thread), + ) @asynccontextmanager async def bind(self, root: str): diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 550dd9665..8ad2310b3 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -3,23 +3,28 @@ from __future__ import annotations from abc import abstractmethod -from agent_comms.declared_family import DeclaredFamily +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, ClassVar + from agent_comms.channel_targets import ( ChannelSelection, CommandTarget, +) +from agent_comms.channel_targets import ( ChannelTarget as CoreChannelTarget, +) +from agent_comms.channel_targets import ( ThreadTarget as CoreThreadTarget, ) -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, ClassVar +from agent_comms.declared_family import DeclaredFamily +from toad.constants import ALL_COMMS_TARGET from toad.conversation_kind import ( - ConversationKind, ChannelConversation, + ConversationKind, DmConversation, IrcConversation, ) -from toad.constants import ALL_COMMS_TARGET if TYPE_CHECKING: from toad.navigation_context import NavigationContext, NavigationOwner @@ -115,26 +120,36 @@ async def open(self, context: NavigationContext) -> str: class HistoryRoute: - """Conversation families declare the history source used for navigation.""" + """A route declaration registers its conversation kind exactly once.""" - @property + history_kind: ClassVar[type[ConversationKind]] + routes: ClassVar[dict[type[ConversationKind], type[HistoryRoute]]] = {} + + def __init_subclass__( + cls, *, history_kind: type[ConversationKind] | None = None, **kwargs + ) -> None: + if history_kind is not None and history_kind in HistoryRoute.routes: + raise TypeError(f"A history route already serves {history_kind.__name__}") + super().__init_subclass__(**kwargs) + if history_kind is not None: + cls.history_kind = history_kind + HistoryRoute.routes[history_kind] = cls + + @classmethod @abstractmethod - def history_kind(self) -> type[ConversationKind]: ... + def for_name(cls, name: str) -> HistoryTarget: ... class HistoryTarget(HistoryRoute, NavigationTarget): @classmethod - def for_name(cls, name: str): + def for_name(cls, name: str) -> HistoryTarget: return cls(name) @classmethod def for_conversation(cls, conversation: ConversationKind) -> HistoryTarget: - (target,) = ( - member - for member in cls.members_with(HistoryRoute) - if member.history_kind is type(conversation) + return HistoryRoute.routes[type(conversation)].for_name( + conversation.target.name ) - return target.for_name(conversation.target.name) async def open(self, context: NavigationContext) -> str: return await context.app.session_navigation.history( @@ -161,9 +176,8 @@ def menu_context(self, sidebar, **kwargs): @dataclass(frozen=True) -class FeedTarget(ChannelLike, HistoryTarget): +class FeedTarget(ChannelLike, HistoryTarget, history_kind=IrcConversation): name: str = field(default=ALL_COMMS_TARGET, init=False) - history_kind = IrcConversation expanded_by_default = True @classmethod @@ -171,12 +185,11 @@ def for_name(cls, name: str): return cls() -class ChannelTarget(ChannelLike, HistoryTarget): - history_kind = ChannelConversation +class ChannelTarget(ChannelLike, HistoryTarget, history_kind=ChannelConversation): + pass -class DirectTarget(HistoryTarget): - history_kind = DmConversation +class DirectTarget(HistoryTarget, history_kind=DmConversation): def selected(self, owner: NavigationOwner) -> None: owner.remember_direct_target(self.name) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 6dea6aeab..8a0d2d377 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -43,7 +43,7 @@ from toad.screens.mcp_inventory import MCPInventoryScreen from toad.screens.session_view import SessionView from toad.session_presentation import OperationalSessionPresentation -from toad.session_scope import ViewSessionOrigin +from toad.session_scope import AgentBinding, ViewSessionOrigin from toad.session_tracker import SidebarState from toad.thread_identity import SessionIdentity, ThreadIdentity from toad.widgets.channels_sidebar import ChannelsSidebar @@ -250,8 +250,7 @@ def __init__( ) -> None: super().__init__() self.set_reactive(MainScreen.project_path, project_path) - self._agent = agent - self._agent_session_id = agent_session_id + self._agent_binding = AgentBinding.of(agent, agent_session_id) self._identity = SessionIdentity.for_thread(agent_session_id or "", None) self._session_pk = session_pk self._initial_prompt = initial_prompt @@ -373,8 +372,8 @@ def make_conversation(self) -> Conversation: ), "Conversation construction requires a registered session" return Conversation( self.project_path, - self._agent, - self._agent_session_id, + self._agent_binding.agent_definition(), + self._agent_binding.saved_session_id(), self._session_pk, self.app.session_tracker.sessions[self.id].initial_title, initial_prompt=self._initial_prompt, @@ -384,7 +383,7 @@ def make_conversation(self) -> Conversation: @property def agent_session_id(self) -> str | None: """The saved ACP session this screen owns.""" - return self._agent_session_id + return self._agent_binding.saved_session_id() def watch_title(self, title: str) -> None: self.app.terminal_attention.update() diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index b6279891f..5f9f4c6f1 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -1,25 +1,25 @@ """Opening and closing sessions use the existing workspace's declared factories.""" from __future__ import annotations -import asyncio -from toad.core import events as core_events -from toad.core.events import CoreEventStream +import asyncio from collections.abc import Callable from itertools import count from pathlib import Path from typing import TYPE_CHECKING, cast - -from agent_comms.presentation import NoRevision, ReadState -from agent_comms.revision_views import RegistrationsView from agent_comms.errors import ( RelationViolationError, UnknownChannelError, UnregisteredThreadError, ) +from agent_comms.presentation import NoRevision, ReadState +from agent_comms.revision_views import RegistrationsView from agent_comms.route_selection import RouteChanged, root_is_current from agent_comms.thread_identity import ThreadIncarnation + +from toad.core import events as core_events +from toad.core.events import CoreEventStream from toad.navigation_preparation import CommsNavigationRequest from toad.session_entry import ( HistorySessionEntry, @@ -31,6 +31,7 @@ if TYPE_CHECKING: from agent_comms.ui_model.sidebar import ThreadRowModel + from toad.app import ToadApp from toad.conversation_kind import ConversationKind from toad.screens.main import MainScreen @@ -212,6 +213,18 @@ async def launch( from toad.screens.main import MainScreen app = self.app + if agent_session_id is not None: + existing = next( + ( + identity + for identity, origin in app.workspace_sessions.native_origins.items() + if origin.matches_launch(agent_identity, agent_session_id) + ), + None, + ) + if existing is not None: + await app.select_session(existing) + return agent, title = None, None if session_pk is not None: saved = await DB().session_get(session_pk) @@ -225,18 +238,6 @@ async def launch( app.show_error("Agent not found", "Launch agent") return project = project_path if project_path is not None else app.project_dir - if agent_session_id is not None: - existing = next( - ( - identity - for identity, origin in app.workspace_sessions.native_origins.items() - if origin.matches_launch(agent_identity, agent_session_id) - ), - None, - ) - if existing is not None: - await app.select_session(existing) - return await self.new( lambda: MainScreen( project, diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index e535c0a5a..f31e0bc1d 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -254,35 +254,118 @@ def agent_for_opening( return default +class AgentBinding(ABC): + """The native launch selection, decoded once from constructor arguments.""" + + @classmethod + def of(cls, agent: AgentDefinition | None, session_id: str | None) -> AgentBinding: + if agent is None: + return NoAgent() + if session_id is None: + return AgentAttached(agent) + return AgentSession(agent, session_id) + + @abstractmethod + def opening_agent( + self, app_agent: AgentDefinition | None + ) -> AgentDefinition | None: ... + + @abstractmethod + def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: ... + + @abstractmethod + def matches_launch( + self, origin: ViewSessionOrigin, agent_identity: str, session_id: str + ) -> bool: ... + + @abstractmethod + def agent_definition(self) -> AgentDefinition | None: ... + + def saved_session_id(self) -> str | None: + return None + + +@dataclass(frozen=True) +class NoAgent(AgentBinding): + def opening_agent( + self, app_agent: AgentDefinition | None + ) -> AgentDefinition | None: + return app_agent + + def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: + return AgentBinding.of(app_agent, None) + + def matches_launch( + self, origin: ViewSessionOrigin, agent_identity: str, session_id: str + ) -> bool: + return False + + def agent_definition(self) -> None: + return None + + +@dataclass(frozen=True) +class AgentAttached(AgentBinding): + agent: AgentDefinition + + def opening_agent(self, app_agent: AgentDefinition | None) -> AgentDefinition: + return self.agent + + def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: + return self + + def agent_definition(self) -> AgentDefinition: + return self.agent + + def matches_launch( + self, origin: ViewSessionOrigin, agent_identity: str, session_id: str + ) -> bool: + return self.agent.identity == agent_identity and self.matches_session( + origin, session_id + ) + + def matches_session(self, origin: ViewSessionOrigin, session_id: str) -> bool: + if origin.presentation.operational_binding.matches_session(session_id): + return True + root = origin.coordination_root + return ( + root is not None + and origin.app.coordination_access.require( + RouteSelection.capture(root) + ).service.registry.canonical_name(session_id) + == origin.resolve_thread() + ) + + +@dataclass(frozen=True) +class AgentSession(AgentAttached): + session_id: str + + def saved_session_id(self) -> str: + return self.session_id + + def matches_session(self, origin: ViewSessionOrigin, session_id: str) -> bool: + return self.session_id == session_id or super().matches_session( + origin, session_id + ) + + class ViewSessionOrigin(ProjectSessionOrigin): """Only actual native-session views own agent/session launch matching.""" - _agent: AgentDefinition | None - _agent_session_id: str | None + _agent_binding: AgentBinding def agent_for_opening( self, default: AgentDefinition | None ) -> AgentDefinition | None: - return self._agent or default + return self._agent_binding.opening_agent(default) def attach_agent_definition(self) -> AgentDefinition | None: - self._agent = self._agent or self.app.agent_data - return self._agent + self._agent_binding = self._agent_binding.attached(self.app.agent_data) + return self._agent_binding.opening_agent(None) def use_mailbox(self) -> None: - self._agent = None + self._agent_binding = NoAgent() def matches_launch(self, agent_identity: str, session_id: str) -> bool: - if self._agent is None or self._agent.identity != agent_identity: - return False - live = self.presentation.operational_binding - if session_id == self._agent_session_id or live.matches_session(session_id): - return True - root = self.coordination_root - return ( - root is not None - and self.app.coordination_access.require( - RouteSelection.capture(root) - ).service.registry.canonical_name(session_id) - == self.resolve_thread() - ) + return self._agent_binding.matches_launch(self, agent_identity, session_id) diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py index 87ee72334..6f50e6284 100644 --- a/src/toad/status_thread.py +++ b/src/toad/status_thread.py @@ -8,7 +8,8 @@ from pathlib import Path from typing import TYPE_CHECKING -from agent_comms.ui_model.observation import UnreadableHistory +from agent_comms.thread_presentation import ThreadPresentation +from agent_comms.ui_model.observation import StoredRead, UnreadableHistory from agent_comms.ui_model.status import ThreadStatusModel from toad.core.events import ThreadRowsChanged @@ -90,19 +91,30 @@ def refresh(self, view: ThreadStatusOwner) -> None: ) async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: - try: - presentation = ( - await view.app.coordination_access.transcripts.thread_presentation( - str(self.root), self.name - ) - ) - row = ThreadStatusModel.known(self.name, presentation) - except (OSError, ValueError, RuntimeError) as error: - presentation = None - row = ThreadStatusModel.unreadable( - self.name, UnreadableHistory.capture(error) - ) + read = await view.app.coordination_access.transcripts.thread_presentation( + str(self.root), self.name + ) + self.present_read(view, read) + + def present_read( + self, view: ThreadStatusOwner, read: StoredRead[ThreadPresentation | None] + ) -> None: if view.status_thread() != self: return - view.thread_activity.show(row) + read.answer( + partial(self.present_value, view), + lambda: None, + partial(self.present_unreadable, view), + ) + + def present_value( + self, view: ThreadStatusOwner, presentation: ThreadPresentation | None + ) -> None: + view.thread_activity.show(ThreadStatusModel.known(self.name, presentation)) view.foreign_thread_read(presentation) + + def present_unreadable( + self, view: ThreadStatusOwner, error: UnreadableHistory + ) -> None: + view.thread_activity.show(ThreadStatusModel.unreadable(self.name, error)) + view.foreign_thread_read(None) diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 7daf96a37..65f9a04b5 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -1,31 +1,30 @@ """Native Toad conversation view for an agent-comms channel or DM.""" from __future__ import annotations -from toad.widgets.presentation_window import OLDER_END, NEWER_END -from toad.core import input_events -from functools import partial -from toad.widgets.wire_history import WireHistory -from toad.message_rows import IrcMessageStyle -from toad.wire_history import WireSource -from toad.transcript_publication import NoView, ViewBinding import re +from functools import partial from pathlib import Path -from agent_comms.comms import Comms +from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision from agent_comms.field_codec import FieldCodec -from agent_comms.mro_dispatch import handles -from toad.core import events as core_events -from toad.core.source_events import TranscriptSourceWorkFinished from agent_comms.messages import Message as WireMessage -from textual import containers, work +from agent_comms.mro_dispatch import handles +from textual import containers from textual.app import ComposeResult from textual.content import Content from textual.widgets import Static from textual.worker import Worker +from toad.command_catalog import CommandCatalog from toad.conversation_kind import ConversationKind -from toad import messages +from toad.core import events as core_events +from toad.core import input_events +from toad.core.source_events import TranscriptSourceWorkFinished +from toad.message_rows import IrcMessageStyle +from toad.session_scope import NoTranscript, SessionScope +from toad.status_thread import StatusThread +from toad.transcript_publication import NoView, ViewBinding from toad.widgets.conversation import ( Contents, ContentsGrid, @@ -33,11 +32,11 @@ CursorContainer, Window, ) -from toad.status_thread import StatusThread from toad.widgets.flash import Flash +from toad.widgets.presentation_window import NEWER_END, OLDER_END from toad.widgets.prompt import Prompt -from toad.session_scope import NoTranscript, SessionScope -from toad.command_catalog import CommandCatalog +from toad.widgets.wire_history import WireHistory +from toad.wire_history import WireSource def session_thread_name(project_path) -> str: @@ -230,38 +229,30 @@ async def _refresh(self) -> Worker | None: async def _refresh_source(self) -> None: """Read what changed in the conversation and draw it: the history's source work.""" - from agent_comms.coordination_errors import ( - CoordinationReadUnavailable, - StaleRevision, - ) - + history = self.message_history + announced = self.app.coordination_access.revision + newly_announced = announced != self._refreshed_from + undrawn = history.undrawn + if undrawn: + self.on_work_started() try: - history = self.message_history - announced = self.app.coordination_access.revision - newly_announced = announced != self._refreshed_from - undrawn = history.undrawn + changed = await history.read_changes() + finally: if undrawn: - self.on_work_started() - try: - changed = await history.read_changes() - finally: - if undrawn: - self.on_work_finished() - # Source observation and its pending first page belong to the - # history, even before this tab is drawable. Composer work follows. - source = history.page_source - snapshot = history.source_snapshot() - if not await history.source_is_current(snapshot): - return - read_only = await source.read_only() - if not await history.source_is_current(snapshot): - return - await self.refresh_composer(read_only) - self._refreshed_from = announced - # A sent message may now be drawn: composing resumes before mentions are read. - self.settle_user_message() - except CoordinationReadUnavailable, StaleRevision: - raise + self.on_work_finished() + # Source observation and its pending first page belong to the + # history, even before this tab is drawable. Composer work follows. + source = history.page_source + snapshot = history.source_snapshot() + if not await history.source_is_current(snapshot): + return + read_only = await source.read_only() + if not await history.source_is_current(snapshot): + return + await self.refresh_composer(read_only) + self._refreshed_from = announced + # A sent message may now be drawn: composing resumes before mentions are read. + self.settle_user_message() # Composer mentions and DM status read the observer's snapshot # and the agent records, which can take seconds on a busy bus. They are # not this conversation's messages, so they run after this work ends @@ -277,11 +268,6 @@ async def _refresh_labels( self, source, changed: bool, newly_announced: bool, read_only: bool ) -> None: """Refresh composer mentions and status for the history as it is drawn now.""" - from agent_comms.coordination_errors import ( - CoordinationReadUnavailable, - StaleRevision, - ) - history = self.message_history snapshot = history.source_snapshot() conversation = self.conversation diff --git a/tests/app/test_agent_binding.py b/tests/app/test_agent_binding.py new file mode 100644 index 000000000..11c1b6a7f --- /dev/null +++ b/tests/app/test_agent_binding.py @@ -0,0 +1,43 @@ +"""Each launch binding keeps matching with its existing native view.""" + +from support.agents import peer + +from toad.screens.main import MainScreen + + +def test_no_agent_does_not_match_a_launch(workspace): + view = MainScreen(workspace.project) + assert not view.matches_launch("not-bound", "not-bound") + assert view.agent_session_id is None + + +async def test_attached_agent_matches_its_live_session_and_relaunch_reuses_the_view( + workspace, +): + definition = peer("echo") + async with workspace.app(agent=definition) as ui: + app, _ = ui.textual + await ui.until_agent_ready() + original = app.selected_session + mode = ui.selected_tab + session_id = ui.conversation.agent_binding.session_id + assert original.matches_launch(definition.identity, session_id) + assert not original.matches_launch(definition.identity + "-other", session_id) + await app.session_navigation.launch( + definition.identity, agent_session_id=session_id + ) + assert ui.selected_tab == mode + assert app.selected_session is original + await ui.write_draft("binding relaunch journey") + await ui.press("enter") + await ui.until_shows("PEER_ANSWER binding relaunch journey") + + +def test_agent_session_matches_its_saved_session_without_crossing_agent_identity( + workspace, +): + definition = peer("echo") + view = MainScreen(workspace.project, definition, "retained-session") + assert view.matches_launch(definition.identity, "retained-session") + assert not view.matches_launch(definition.identity + "-other", "retained-session") + assert view.agent_session_id == "retained-session" diff --git a/tests/app/test_foreign_status_read.py b/tests/app/test_foreign_status_read.py new file mode 100644 index 000000000..62fe901e2 --- /dev/null +++ b/tests/app/test_foreign_status_read.py @@ -0,0 +1,52 @@ +"""Deferred Core reads retain the actual mounted status display.""" + +import pytest +from agent_comms.bus_route_counts import RouteIndexRebuilding +from agent_comms.coordination_errors import CoordinationReadUnavailable +from agent_comms.ui_model.observation import ReadDeferred, ReadPreparing +from agent_comms.ui_model.status import ThreadStatusModel +from textual.app import ComposeResult +from textual.containers import Vertical + +from toad.status_thread import ForeignStatusThread +from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, +) + + +class ForeignStatusSurface(ThreadStatusOwner, Vertical): + thread_activity: ObservedThreadActivity + + def __init__(self, source: ForeignStatusThread): + super().__init__() + self.source = source + self.thread_activity = ObservedThreadActivity() + + def compose(self) -> ComposeResult: + yield self.thread_activity + + def on_mount(self) -> None: + self.thread_activity.bind(self.source) + + def status_thread(self) -> ForeignStatusThread: + return self.source + + +@pytest.mark.parametrize( + "read", + [ + ReadDeferred(CoordinationReadUnavailable("busy snapshot")), + ReadPreparing(RouteIndexRebuilding(1, 2)), + ], +) +async def test_deferred_foreign_read_keeps_the_previous_status_row(workspace, read): + async with workspace.app() as ui: + app, _ = ui.textual + source = ForeignStatusThread(workspace.project / "foreign", "peer") + surface = ForeignStatusSurface(source) + await app.selected_session.mount(surface) + previous = ThreadStatusModel.pending("previous-status") + surface.thread_activity.show(previous) + source.present_read(surface, read) + assert surface.thread_activity.row is previous diff --git a/tests/model/test_conversation_kind.py b/tests/model/test_conversation_kind.py index e7e7eab1f..66b06fedf 100644 --- a/tests/model/test_conversation_kind.py +++ b/tests/model/test_conversation_kind.py @@ -2,16 +2,15 @@ from dataclasses import FrozenInstanceError, fields from functools import partial -from toad.status_thread import NoStatusThread import pytest - from agent_comms.candidate_maintenance import drain_candidate_catchup from agent_comms.channel_targets import ChannelTarget, ThreadTarget from agent_comms.comms import Comms, wire from agent_comms.field_codec import FieldCodec from agent_comms.installer.backup import StoreBackup from agent_comms.message_page import PageBounds + from toad.constants import ALL_COMMS_TARGET from toad.conversation_kind import ( ChannelConversation, @@ -20,8 +19,10 @@ IrcConversation, ResolvedConversation, ) -from toad.session_tracker import CommsViewKey from toad.message_rows import IrcMessageStyle +from toad.navigation_target import DirectTarget, HistoryTarget +from toad.session_tracker import CommsViewKey +from toad.status_thread import NoStatusThread from toad.wire_history import WireSource @@ -88,6 +89,19 @@ class TeamConversation(ChannelConversation): assert conversation.label() == "#team" +def test_history_routes_reject_duplicate_declarations_and_unregistered_kinds(): + with pytest.raises(TypeError): + + class DuplicateDirectTarget(DirectTarget, history_kind=DmConversation): + pass + + class UnroutedConversation(ChannelConversation): + pass + + with pytest.raises(KeyError): + HistoryTarget.for_conversation(UnroutedConversation(ChannelTarget("#team"))) + + def test_conversation_value_is_frozen_and_its_kind_is_part_of_tab_identity(): conversation = ChannelConversation(ChannelTarget("#team")) assert [field.name for field in fields(conversation)] == ["target"] From 610e42f264853ea1c0f55b841c049a6ddd79cfc8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 09:44:52 -0400 Subject: [PATCH 14/43] Exercise real foreign stored reads alongside deferred status preservation --- tests/app/test_foreign_status_read.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/app/test_foreign_status_read.py b/tests/app/test_foreign_status_read.py index 62fe901e2..a7c619e3f 100644 --- a/tests/app/test_foreign_status_read.py +++ b/tests/app/test_foreign_status_read.py @@ -2,6 +2,7 @@ import pytest from agent_comms.bus_route_counts import RouteIndexRebuilding +from agent_comms.comms import Comms from agent_comms.coordination_errors import CoordinationReadUnavailable from agent_comms.ui_model.observation import ReadDeferred, ReadPreparing from agent_comms.ui_model.status import ThreadStatusModel @@ -50,3 +51,23 @@ async def test_deferred_foreign_read_keeps_the_previous_status_row(workspace, re surface.thread_activity.show(previous) source.present_read(surface, read) assert surface.thread_activity.row is previous + + +async def test_foreign_read_presents_the_real_stored_thread(workspace): + foreign = Comms( + workspace.roots.attempt / "foreign-wire", private_initial_writes=True + ) + foreign.registry.declare( + foreign.registry.new_thread( + "peer", frozenset(), str(workspace.project), created_at=10.0 + ) + ) + async with workspace.app() as ui: + app, _ = ui.textual + source = ForeignStatusThread(foreign.root, "peer") + surface = ForeignStatusSurface(source) + await app.selected_session.mount(surface) + await source.read_foreign_thread(surface) + assert surface.thread_activity.row == ThreadStatusModel.known( + "peer", foreign.views.thread_presentations(("peer",)).read("peer") + ) From 3c8a4f93301aa5d7d3d2264439b81712ef5deccd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 09:55:37 -0400 Subject: [PATCH 15/43] Use the current Comms constructor in the foreign read journey --- tests/app/test_foreign_status_read.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/app/test_foreign_status_read.py b/tests/app/test_foreign_status_read.py index a7c619e3f..19d757d4f 100644 --- a/tests/app/test_foreign_status_read.py +++ b/tests/app/test_foreign_status_read.py @@ -54,9 +54,7 @@ async def test_deferred_foreign_read_keeps_the_previous_status_row(workspace, re async def test_foreign_read_presents_the_real_stored_thread(workspace): - foreign = Comms( - workspace.roots.attempt / "foreign-wire", private_initial_writes=True - ) + foreign = Comms(workspace.roots.attempt / "foreign-wire") foreign.registry.declare( foreign.registry.new_thread( "peer", frozenset(), str(workspace.project), created_at=10.0 From 3d474abe099bdd9c43aaec8b31b6ec2f6dd3dfcb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:22:53 -0400 Subject: [PATCH 16/43] Construct native binding members and own routed target registration --- src/toad/navigation_preparation.py | 20 +- src/toad/navigation_target.py | 35 +-- src/toad/screens/main.py | 41 ++-- src/toad/session_entry.py | 17 +- src/toad/session_navigation.py | 8 +- src/toad/session_presentation.py | 21 +- src/toad/session_scope.py | 91 ++++---- src/toad/thread_identity.py | 18 +- src/toad/widgets/conversation.py | 138 ++++++------ tests/app/test_agent_binding.py | 8 +- tests/model/test_conversation_kind.py | 2 +- tests/support/ui.py | 8 +- tests/textual/test_plan_display.py | 203 ++++++++++++++---- .../test_sidebar_state_across_views.py | 105 +++++++-- ...rkspace_tabs_share_one_agent_connection.py | 36 +++- 15 files changed, 482 insertions(+), 269 deletions(-) diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index 27c638ad9..3c7dcc521 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -1,7 +1,6 @@ """Bounded route discovery without accessing widgets from a reader thread.""" from __future__ import annotations -from acp.exceptions import RequestError import asyncio from abc import ABC, abstractmethod @@ -9,17 +8,19 @@ from pathlib import Path from typing import TYPE_CHECKING, Generic, TypeVar, cast -from agent_comms.threads import Thread -from agent_comms.thread_identity import ThreadIncarnation +from acp.exceptions import RequestError from agent_comms.comms import Comms -from agent_comms.registry_document import RegistrySnapshot from agent_comms.declared_family import DeclaredFamily -from agent_comms.thread_execution import ConversationPreparation +from agent_comms.registry_document import RegistrySnapshot from agent_comms.session_load import ExistingSessionLoadPlan -from toad.owner_preparation import ExistingOwnerPlan +from agent_comms.thread_execution import ConversationPreparation +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.threads import Thread -from toad.session_tracker import CommsViewKey from toad.conversation_kind import ConversationKind +from toad.owner_preparation import ExistingOwnerPlan +from toad.session_scope import NoAgent +from toad.session_tracker import CommsViewKey ResultT = TypeVar("ResultT") @@ -88,7 +89,10 @@ def launch_screen(self, app): from toad.screens.main import MainScreen screen = MainScreen.from_thread( - self.project, self.root, self.thread.incarnation, app.agent_data + self.project, + self.root, + self.thread.incarnation, + NoAgent().attached(app.agent_data), ) self.bind(screen) return screen diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 8ad2310b3..f6addc261 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -122,19 +122,8 @@ async def open(self, context: NavigationContext) -> str: class HistoryRoute: """A route declaration registers its conversation kind exactly once.""" - history_kind: ClassVar[type[ConversationKind]] routes: ClassVar[dict[type[ConversationKind], type[HistoryRoute]]] = {} - def __init_subclass__( - cls, *, history_kind: type[ConversationKind] | None = None, **kwargs - ) -> None: - if history_kind is not None and history_kind in HistoryRoute.routes: - raise TypeError(f"A history route already serves {history_kind.__name__}") - super().__init_subclass__(**kwargs) - if history_kind is not None: - cls.history_kind = history_kind - HistoryRoute.routes[history_kind] = cls - @classmethod @abstractmethod def for_name(cls, name: str) -> HistoryTarget: ... @@ -151,6 +140,20 @@ def for_conversation(cls, conversation: ConversationKind) -> HistoryTarget: conversation.target.name ) + +class HistoryRouted(HistoryTarget, declared_name=None): + """Routed destinations supply their kind; the intermediate is not a destination.""" + + history_kind: ClassVar[type[ConversationKind]] + + def __init_subclass__(cls, **kwargs) -> None: + if cls.history_kind in HistoryRoute.routes: + raise TypeError( + f"A history route already serves {cls.history_kind.__name__}" + ) + super().__init_subclass__(**kwargs) + HistoryRoute.routes[cls.history_kind] = cls + async def open(self, context: NavigationContext) -> str: return await context.app.session_navigation.history( owner_mode=context.owner_mode, @@ -176,7 +179,8 @@ def menu_context(self, sidebar, **kwargs): @dataclass(frozen=True) -class FeedTarget(ChannelLike, HistoryTarget, history_kind=IrcConversation): +class FeedTarget(ChannelLike, HistoryRouted): + history_kind = IrcConversation name: str = field(default=ALL_COMMS_TARGET, init=False) expanded_by_default = True @@ -185,11 +189,12 @@ def for_name(cls, name: str): return cls() -class ChannelTarget(ChannelLike, HistoryTarget, history_kind=ChannelConversation): - pass +class ChannelTarget(ChannelLike, HistoryRouted): + history_kind = ChannelConversation -class DirectTarget(HistoryTarget, history_kind=DmConversation): +class DirectTarget(HistoryRouted): + history_kind = DmConversation def selected(self, owner: NavigationOwner) -> None: owner.remember_direct_target(self.name) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 8a0d2d377..81767a00b 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -25,7 +25,6 @@ ) from toad.acp.agent_session import SessionDisposition -from toad.agent_schema import AgentDefinition from toad.app import ToadApp from toad.core import events as core_events from toad.core import input_events, session_requests @@ -43,9 +42,9 @@ from toad.screens.mcp_inventory import MCPInventoryScreen from toad.screens.session_view import SessionView from toad.session_presentation import OperationalSessionPresentation -from toad.session_scope import AgentBinding, ViewSessionOrigin +from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin from toad.session_tracker import SidebarState -from toad.thread_identity import SessionIdentity, ThreadIdentity +from toad.thread_identity import NoThreadIdentity, ThreadIdentity from toad.widgets.channels_sidebar import ChannelsSidebar from toad.widgets.conversation import Conversation from toad.widgets.message_notifications import NothingToRead, PaintedRead, ReadWork @@ -243,15 +242,14 @@ def side_bar(self): def __init__( self, project_path: Path, - agent: AgentDefinition | None = None, - agent_session_id: str | None = None, + binding: AgentBinding = NoAgent(), session_pk: int | None = None, initial_prompt: str | None = None, ) -> None: super().__init__() self.set_reactive(MainScreen.project_path, project_path) - self._agent_binding = AgentBinding.of(agent, agent_session_id) - self._identity = SessionIdentity.for_thread(agent_session_id or "", None) + self._agent_binding = binding + self._identity = binding.initial_identity(NoThreadIdentity()) self._session_pk = session_pk self._initial_prompt = initial_prompt self._thread_sidebar_state = SidebarState() @@ -300,9 +298,9 @@ async def mark(): @classmethod def from_thread( - cls, project: Path, root: str, target, agent: AgentDefinition | None = None + cls, project: Path, root: str, target, binding: AgentBinding = NoAgent() ) -> "MainScreen": - screen = cls(project, agent, agent_session_id=target.name) + screen = cls(project, binding.for_session(target.name)) screen.bind_thread(target, root) return screen @@ -321,8 +319,7 @@ def opening(cls, request, origin, app) -> "MainScreen": # The request has only a name; bind the wire after its metadata read. screen = cls( request.project, - origin.source.agent_for_opening(app.agent_data), - agent_session_id=request.target, + origin.source.agent_for_opening(app.agent_data).for_session(request.target), ) screen.content = NavigatedOpeningContent() return screen @@ -370,21 +367,17 @@ def make_conversation(self) -> Conversation: assert ( self.id is not None ), "Conversation construction requires a registered session" - return Conversation( - self.project_path, - self._agent_binding.agent_definition(), - self._agent_binding.saved_session_id(), - self._session_pk, - self.app.session_tracker.sessions[self.id].initial_title, - initial_prompt=self._initial_prompt, - transcript=self.presentation.transcript, + return self._agent_binding.make_conversation( + partial( + Conversation, + self.project_path, + session_pk=self._session_pk, + session_title=self.app.session_tracker.sessions[self.id].initial_title, + initial_prompt=self._initial_prompt, + transcript=self.presentation.transcript, + ) ) - @property - def agent_session_id(self) -> str | None: - """The saved ACP session this screen owns.""" - return self._agent_binding.saved_session_id() - def watch_title(self, title: str) -> None: self.app.terminal_attention.update() diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index 47beef67a..7d162b945 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -13,25 +13,24 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.route_selection import RouteSelection -from toad.owner_preparation import ExistingOwnerPlan -from toad.session_scope import SessionScope from agent_comms.thread_identity import ThreadIncarnation - from agent_comms.ui_model.sidebar import ThreadRowModel +from agent_comms.ui_model.unread import UnobservedUnread +from toad.owner_preparation import ExistingOwnerPlan +from toad.session_scope import SessionScope from toad.session_tracker import ( CommsViewKey, - OpenTab, NamedTab, - ThreadTab, + OpenTab, SessionDetails, + ThreadTab, ) -from agent_comms.ui_model.unread import UnobservedUnread if TYPE_CHECKING: - from toad.session_navigation import SessionNavigation - from toad.screens.main import MainScreen from toad.comms_root import Observing + from toad.screens.main import MainScreen + from toad.session_navigation import SessionNavigation class SessionEntry(DeclaredFamily, affix="Entry"): @@ -127,7 +126,7 @@ def original_threads(self, sessions): def __call__(self) -> MainScreen: source = self.factory() - self.details.bind_initial_identity(source.agent_session_id) + source.thread_identity.bind_initial_identity(self.details) return source @property diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index 5f9f4c6f1..d5b2be058 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Callable +from functools import partial from itertools import count from pathlib import Path from typing import TYPE_CHECKING, cast @@ -27,11 +28,13 @@ PreviewSessionEntry, SessionEntry, ) +from toad.session_scope import AgentAttached, AgentBinding, AgentSession from toad.session_tracker import OpenTab, SessionDetails if TYPE_CHECKING: from agent_comms.ui_model.sidebar import ThreadRowModel + from toad.agent_schema import AgentDefinition from toad.app import ToadApp from toad.conversation_kind import ConversationKind from toad.screens.main import MainScreen @@ -213,7 +216,9 @@ async def launch( from toad.screens.main import MainScreen app = self.app + make_binding: Callable[[AgentDefinition], AgentBinding] = AgentAttached if agent_session_id is not None: + make_binding = partial(AgentSession, session_id=agent_session_id) existing = next( ( identity @@ -241,8 +246,7 @@ async def launch( await self.new( lambda: MainScreen( project, - agent, - agent_session_id, + make_binding(agent), session_pk=session_pk, initial_prompt=initial_prompt, ), diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 8ac8fd177..355fba6bc 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -3,26 +3,25 @@ import asyncio from abc import ABC, abstractmethod from contextlib import asynccontextmanager -from toad.resource_release import AsyncResourceRelease from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from weakref import ref +from agent_comms.acp_extension import CoordinationChangedUpdate +from agent_comms.session_load import EnsuringSessionLoadPlan +from agent_comms.ui_model.message_category import MessageCategory from textual.widget import Widget from textual.widgets.text_area import TextAreaState -from agent_comms.acp_extension import CoordinationChangedUpdate +from toad.agent_binding import NoAgentBound +from toad.agent_surface import AttachedSurfaceBinding from toad.conversation_kind import DmConversation -from agent_comms.session_load import EnsuringSessionLoadPlan -from toad.owner_preparation import ExistingOwnerPlan - from toad.input_history import InputHistories -from toad.agent_surface import AttachedSurfaceBinding -from toad.agent_binding import NoAgentBound +from toad.owner_preparation import ExistingOwnerPlan +from toad.resource_release import AsyncResourceRelease from toad.widgets.conversation import Conversation from toad.widgets.history_anchor import ParkedPosition -from agent_comms.ui_model.message_category import MessageCategory if TYPE_CHECKING: from toad.app import ToadApp @@ -209,12 +208,10 @@ def create_agent(self, conversation: Conversation) -> None: def attach_started(self, screen: "MainScreen", plan) -> None: """An explicit Start action may attach the same retained view to its new owner.""" self.load_plan = plan - agent = screen.attach_agent_definition() + binding = screen.attach_agent_definition() conversation = screen.presentation.widget if conversation is not None: - conversation._agent_data = agent - if screen.is_current: - conversation.start_native_session() + binding.attach_to(screen, conversation) async def present(self, conversation: Conversation) -> None: shell, self.shell = self.shell, None diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index f31e0bc1d..7a2f45ce3 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -31,6 +31,7 @@ from toad.navigation_target import NavigationTarget from toad.session_presentation import OperationalSessionPresentation from toad.thread_identity import SessionIdentity + from toad.widgets.conversation import Conversation class TranscriptScope(ABC): @@ -248,74 +249,75 @@ def resolve_thread(self) -> str: service = self.app.coordination_access.require(selected).service return identity.resolve_thread(service.registry, self.project_root) - def agent_for_opening( - self, default: AgentDefinition | None - ) -> AgentDefinition | None: - return default + def agent_for_opening(self, default: AgentDefinition | None) -> AgentBinding: + return NoAgent().attached(default) class AgentBinding(ABC): - """The native launch selection, decoded once from constructor arguments.""" + """Known native launch state carries construction and attachment behavior.""" - @classmethod - def of(cls, agent: AgentDefinition | None, session_id: str | None) -> AgentBinding: - if agent is None: - return NoAgent() - if session_id is None: - return AgentAttached(agent) - return AgentSession(agent, session_id) + @abstractmethod + def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: ... @abstractmethod - def opening_agent( - self, app_agent: AgentDefinition | None - ) -> AgentDefinition | None: ... + def for_session(self, session_id: str) -> AgentBinding: ... @abstractmethod - def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: ... + def make_conversation(self, constructor: partial[Conversation]) -> Conversation: ... + + @abstractmethod + def attach_to( + self, origin: ViewSessionOrigin, conversation: Conversation + ) -> None: ... @abstractmethod def matches_launch( self, origin: ViewSessionOrigin, agent_identity: str, session_id: str ) -> bool: ... - @abstractmethod - def agent_definition(self) -> AgentDefinition | None: ... - - def saved_session_id(self) -> str | None: - return None + def initial_identity(self, empty: SessionIdentity) -> SessionIdentity: + return empty @dataclass(frozen=True) class NoAgent(AgentBinding): - def opening_agent( - self, app_agent: AgentDefinition | None - ) -> AgentDefinition | None: - return app_agent - def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: - return AgentBinding.of(app_agent, None) + if app_agent is None: + return self + return AgentAttached(app_agent) + + def for_session(self, session_id: str) -> AgentBinding: + return self + + def make_conversation(self, constructor: partial[Conversation]) -> Conversation: + return constructor() + + def attach_to(self, origin: ViewSessionOrigin, conversation: Conversation) -> None: + conversation.clear_agent_definition() def matches_launch( self, origin: ViewSessionOrigin, agent_identity: str, session_id: str ) -> bool: return False - def agent_definition(self) -> None: - return None - @dataclass(frozen=True) class AgentAttached(AgentBinding): agent: AgentDefinition - def opening_agent(self, app_agent: AgentDefinition | None) -> AgentDefinition: - return self.agent - def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: return self - def agent_definition(self) -> AgentDefinition: - return self.agent + def for_session(self, session_id: str) -> AgentBinding: + return AgentSession(self.agent, session_id) + + def make_conversation(self, constructor: partial[Conversation]) -> Conversation: + return constructor(agent=self.agent) + + def attach_to(self, origin: ViewSessionOrigin, conversation: Conversation) -> None: + conversation.attach_agent_definition(self.agent) + if origin.is_current: + conversation.start_native_session() def matches_launch( self, origin: ViewSessionOrigin, agent_identity: str, session_id: str @@ -341,8 +343,13 @@ def matches_session(self, origin: ViewSessionOrigin, session_id: str) -> bool: class AgentSession(AgentAttached): session_id: str - def saved_session_id(self) -> str: - return self.session_id + def make_conversation(self, constructor: partial[Conversation]) -> Conversation: + return super().make_conversation( + partial(constructor, agent_session_id=self.session_id) + ) + + def initial_identity(self, empty: SessionIdentity) -> SessionIdentity: + return empty.with_session(self.session_id) def matches_session(self, origin: ViewSessionOrigin, session_id: str) -> bool: return self.session_id == session_id or super().matches_session( @@ -355,14 +362,12 @@ class ViewSessionOrigin(ProjectSessionOrigin): _agent_binding: AgentBinding - def agent_for_opening( - self, default: AgentDefinition | None - ) -> AgentDefinition | None: - return self._agent_binding.opening_agent(default) + def agent_for_opening(self, default: AgentDefinition | None) -> AgentBinding: + return self._agent_binding.attached(default) - def attach_agent_definition(self) -> AgentDefinition | None: + def attach_agent_definition(self) -> AgentBinding: self._agent_binding = self._agent_binding.attached(self.app.agent_data) - return self._agent_binding.opening_agent(None) + return self._agent_binding def use_mailbox(self) -> None: self._agent_binding = NoAgent() diff --git a/src/toad/thread_identity.py b/src/toad/thread_identity.py index d2d4d5200..08bdb1831 100644 --- a/src/toad/thread_identity.py +++ b/src/toad/thread_identity.py @@ -4,12 +4,18 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from toad.format_path import format_path -from toad.transcript_state import UnboundTranscript, UnreadTranscript -from toad.session_scope import KnownTranscript, NoTranscript, TranscriptScope +from typing import TYPE_CHECKING + from agent_comms.errors import UnregisteredThreadError from agent_comms.thread_identity import ThreadIncarnation, UnrecordedThreadIncarnation +from toad.format_path import format_path +from toad.session_scope import KnownTranscript, NoTranscript, TranscriptScope +from toad.transcript_state import UnboundTranscript, UnreadTranscript + +if TYPE_CHECKING: + from toad.session_tracker import SessionDetails + class SessionIdentity(ABC): @classmethod @@ -69,6 +75,9 @@ def thread_incarnation(self) -> ThreadIncarnation: def bind_history(self, histories) -> None: """An unbound session keeps its native input-history scope.""" + def bind_initial_identity(self, details: "SessionDetails") -> None: + """A session without a thread has no initial thread title.""" + def resolve_thread(self, registry, project: Path) -> str: """An unbound view asks Core to discover its project's session.""" return registry.session_thread(project, self.thread_name()).name_or( @@ -95,6 +104,9 @@ def command_target[T](self, target: Callable[[str], T]) -> T: @dataclass(frozen=True) class NamedThreadIdentity(SessionIdentity, ABC): + def bind_initial_identity(self, details: "SessionDetails") -> None: + details.bind_initial_identity(self.thread_name()) + def display_thread(self) -> str: return f"@{self.thread_name()}" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 29ca929b6..d485d65c0 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1,31 +1,22 @@ from __future__ import annotations -from toad.core import input_events -from toad.core_event_carrier import CoreEventMessage -from toad.widget_view import WorkspaceHosted -from toad.session_scope import SessionScope, TranscriptScope - -from toad.conversation_submission import ConversationSubmissions -from toad.live_output import LiveOutput, ThoughtStream -from toad.transcript_publication import BoundView, TranscriptPresentation, ViewBinding -from toad.goal_interaction import GoalSession - -from toad.core.preference_events import PreferenceChanged -from toad.preferences import SETTINGS import asyncio from abc import abstractmethod from contextlib import suppress -from toad.resource_release import AsyncResourceRelease +from functools import cached_property from itertools import filterfalse - from operator import attrgetter from pathlib import Path from time import monotonic, time_ns from typing import TYPE_CHECKING, Any, Callable, Literal -from agent_comms.goal_presentation import ThreadGoalExecution, IdleGoalExecution +from acp import schema as acp_protocol +from agent_comms.goal_presentation import IdleGoalExecution, ThreadGoalExecution +from agent_comms.goals import NoGoal from agent_comms.mro_dispatch import handles -from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications +from agent_comms.ui_model.delivery import InputDelivery +from agent_comms.ui_model.goal import GoalDisplay +from agent_comms.ui_model.message_category import MessageCategory, all_categories from rich.segment import Segment from rich.text import Text as RichText from textual import containers, events, getters, on, work @@ -42,81 +33,84 @@ from textual.strip import Strip from textual.widget import Widget from textual.widgets import Static +from ui_theme.effects import CURSOR_BLINK, Mix from toad import messages, paths -from toad.widgets.splash_text import ThreadLoading - -from toad.core import events as core_events -from toad.widgets.comms_menu import ContextMenu -from toad.conversation_comms_handlers import ConversationCommsHandlers -from toad.core_event_carrier import CoreEventReceiver -from acp import schema as acp_protocol -from toad.acp.status import StopReason from toad.acp.comms_updates import NoPendingThreadRead -from toad.core.events import AgentFail, AgentReady +from toad.acp.status import StopReason from toad.agent import AgentBase from toad.agent_binding import AgentBinding, NoAgentBound -from toad.widgets.terminal_tool import TerminalTool from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.app import ToadApp +from toad.block_navigation import ( + ContentNavigation, + ConversationBlock, + ConversationEntry, + DownCursor, + UpCursor, + require_blocks, +) +from toad.command_catalog import CommandCatalog +from toad.conversation_comms_handlers import ConversationCommsHandlers +from toad.conversation_submission import ConversationSubmissions +from toad.conversation_turn import ConversationTurn, TurnOwner +from toad.core import events as core_events +from toad.core import input_events +from toad.core.events import AgentFail, AgentReady +from toad.core.preference_events import PreferenceChanged from toad.core.source_events import ( CurrentWorkingDirectoryChanged, TranscriptCoverage, TranscriptSourceWorkFinished, ) -from toad.input_history import InputHistories -from toad.widgets.flash import Flash -from toad.widgets.menu import Menu -from toad.widgets.note import Note -from toad.widgets.prompt import Prompt -from toad.widgets.terminal import Terminal -from toad.widgets.throbber import AboveThrobber, BelowThrobber +from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.frame_presentation import Animated -from ui_theme.effects import CURSOR_BLINK, Mix -from agent_comms.ui_model.delivery import InputDelivery -from agent_comms.goals import NoGoal -from agent_comms.ui_model.goal import GoalDisplay +from toad.goal_interaction import GoalSession +from toad.input_history import InputHistories +from toad.layout import trim_trailing_margin +from toad.live_output import LiveOutput, ThoughtStream +from toad.menus import MenuItem +from toad.preferences import SETTINGS +from toad.private_native_cursor import CursorStatus, HiddenCursorStatus +from toad.resource_release import AsyncResourceRelease from toad.session_observation import ( GoalObservation, InputDeliveryObservation, SessionObservation, ) -from toad.widgets.goal_bar import GoalBar -from toad.widgets.native_history import NativeHistory -from toad.widgets.observed_thread_activity import ( - ObservedThreadActivity, - ThreadStatusOwner, -) -from toad.widgets.cli_draft import CliDraft -from toad.status_thread import StatusThread -from toad.widgets.transcript_history import JumpToLatest, TranscriptHistory -from toad.widgets.session_details import OperationalSessionDetails, SessionDetails -from toad.private_native_cursor import CursorStatus, HiddenCursorStatus -from toad.block_navigation import ( - require_blocks, - ConversationBlock, - ConversationEntry, - ContentNavigation, - UpCursor, - DownCursor, -) -from functools import cached_property -from toad.agent_presentation import AgentAttachmentView -from toad.conversation_turn import TurnOwner, ConversationTurn +from toad.session_scope import SessionScope, TranscriptScope from toad.shell import Shell +from toad.status_thread import StatusThread +from toad.transcript_publication import BoundView, TranscriptPresentation, ViewBinding +from toad.widget_view import WorkspaceHosted +from toad.widgets.cli_draft import CliDraft +from toad.widgets.comms_menu import ContextMenu +from toad.widgets.flash import Flash +from toad.widgets.goal_bar import GoalBar from toad.widgets.history_anchor import HistoryWindow from toad.widgets.input_delivery import ( DetailAction, InputDeliveryBar, InputDeliveryDetails, ) -from toad.widgets.user_input import UserInput -from agent_comms.ui_model.message_category import all_categories, MessageCategory -from toad.layout import trim_trailing_margin -from toad.command_catalog import CommandCatalog -from toad.menus import MenuItem +from toad.widgets.menu import Menu +from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications +from toad.widgets.native_history import NativeHistory +from toad.widgets.note import Note +from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, +) +from toad.widgets.prompt import Prompt +from toad.widgets.session_details import OperationalSessionDetails, SessionDetails from toad.widgets.shell_terminal import ShellTerminal +from toad.widgets.splash_text import ThreadLoading +from toad.widgets.terminal import Terminal +from toad.widgets.terminal_tool import TerminalTool +from toad.widgets.throbber import AboveThrobber, BelowThrobber +from toad.widgets.transcript_history import JumpToLatest, TranscriptHistory +from toad.widgets.user_input import UserInput AUTO_SESSION_TITLE_MAX_LENGTH = 50 @@ -684,6 +678,13 @@ def refresh_native_projection(self) -> None: """Publish bound owner facts and invalidate the delivery read resource.""" self.agent_binding.refresh_projection(self) + def attach_agent_definition(self, definition: AgentDefinition) -> None: + """Selection changes write the conversation's definition at its owner.""" + self._agent_data = definition + + def clear_agent_definition(self) -> None: + self._agent_data = None + def start_native_session(self) -> None: self.agent_binding.start_session(self) @@ -779,11 +780,9 @@ def watch_agent_binding(self, binding: AgentBinding) -> None: self.update_slash_commands() -from toad.widget_actions import DeclaredWidgetActions from toad.conversation_actions import ConversationAction - - from toad.delivery_failure_view import DeliveryFailureView +from toad.widget_actions import DeclaredWidgetActions class Conversation( @@ -1176,7 +1175,9 @@ def on_provider_login(self, event: CoreEventMessage): @work(group="provider-login", exclusive=True) async def action_provider_login(self) -> None: import shlex + from textual.geometry import Offset + from toad.agent_schema import Command from toad.catalog_actions import CatalogCommandAction from toad.screens.action_modal import ActionModal @@ -1361,7 +1362,7 @@ async def on_menu_option_selected(self, event: Menu.OptionSelected) -> None: and self.navigation.accepts_menu_owner(event.owner) ): await self.run_action(event.action, {"block": event.owner}) - if (cursor_block := self.cursor_block_child) is not None: + if self.cursor_block_child is not None: self.call_after_refresh(self.cursor.refresh) @handles(CurrentWorkingDirectoryChanged) @@ -1440,7 +1441,6 @@ def on_worker_state_changed(self, message) -> None: @handles(core_events.Thinking) async def on_acp_agent_thinking(self, message: CoreEventMessage): self.turns.describe("Thinking…") - activity = " ".join(message.event.text.splitlines()).strip() or "Thinking" await self.output.append(ThoughtStream(), message.event.text) @handles(core_events.RequestPermission) diff --git a/tests/app/test_agent_binding.py b/tests/app/test_agent_binding.py index 11c1b6a7f..9d2105029 100644 --- a/tests/app/test_agent_binding.py +++ b/tests/app/test_agent_binding.py @@ -3,12 +3,14 @@ from support.agents import peer from toad.screens.main import MainScreen +from toad.session_scope import AgentSession +from toad.thread_identity import NoThreadIdentity def test_no_agent_does_not_match_a_launch(workspace): view = MainScreen(workspace.project) assert not view.matches_launch("not-bound", "not-bound") - assert view.agent_session_id is None + assert view.thread_identity == NoThreadIdentity() async def test_attached_agent_matches_its_live_session_and_relaunch_reuses_the_view( @@ -37,7 +39,7 @@ def test_agent_session_matches_its_saved_session_without_crossing_agent_identity workspace, ): definition = peer("echo") - view = MainScreen(workspace.project, definition, "retained-session") + view = MainScreen(workspace.project, AgentSession(definition, "retained-session")) assert view.matches_launch(definition.identity, "retained-session") assert not view.matches_launch(definition.identity + "-other", "retained-session") - assert view.agent_session_id == "retained-session" + assert view.thread_identity.thread_name() == "retained-session" diff --git a/tests/model/test_conversation_kind.py b/tests/model/test_conversation_kind.py index 66b06fedf..14609bcd4 100644 --- a/tests/model/test_conversation_kind.py +++ b/tests/model/test_conversation_kind.py @@ -92,7 +92,7 @@ class TeamConversation(ChannelConversation): def test_history_routes_reject_duplicate_declarations_and_unregistered_kinds(): with pytest.raises(TypeError): - class DuplicateDirectTarget(DirectTarget, history_kind=DmConversation): + class DuplicateDirectTarget(DirectTarget): pass class UnroutedConversation(ChannelConversation): diff --git a/tests/support/ui.py b/tests/support/ui.py index 33446c8f2..b7db73747 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -7,18 +7,20 @@ from __future__ import annotations -from abc import ABC, abstractmethod import asyncio +import inspect +from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass -import inspect from typing import TYPE_CHECKING from support.agents import AppAgents from toad.navigation_context import NavigationContext +from toad.session_scope import NoAgent if TYPE_CHECKING: from textual.pilot import Pilot + from toad.app import ToadApp @@ -317,7 +319,7 @@ async def new_session(self) -> str: app = self.app details = await app.session_navigation.new( - lambda: MainScreen(app.project_dir, app.agent_data) + lambda: MainScreen(app.project_dir, NoAgent().attached(app.agent_data)) ) await self.step() return details.mode_name diff --git a/tests/textual/test_plan_display.py b/tests/textual/test_plan_display.py index 49cc1ce36..b947f3a35 100644 --- a/tests/textual/test_plan_display.py +++ b/tests/textual/test_plan_display.py @@ -1,23 +1,29 @@ """Textual-facing: a plan sent by an ACP agent is drawn as one Plan widget in the conversation and in the sidebar (status markers, completion strike-through animation, reset, return), and malformed plan updates are rejected with a visible note.""" import asyncio -from contextlib import asynccontextmanager import json +from contextlib import asynccontextmanager from typing import get_args from acp.schema import PlanEntryStatus - from support.agents import peer from support.textual_steps import painted_text, viewport_text + from toad.plan import PlanStatus from toad.screens.main import MainScreen from toad.widgets.note import Note from toad.widgets.plan import Plan from toad.widgets.strike_text import StrikeText -FIRST = {"entries": [{"content": f"ACP_{name}_ITEM", "priority": "high", "status": name} - for name in get_args(PlanEntryStatus)]} -ALL_COMPLETED = {"entries": [{**entry, "status": "completed"} for entry in FIRST["entries"]]} +FIRST = { + "entries": [ + {"content": f"ACP_{name}_ITEM", "priority": "high", "status": name} + for name in get_args(PlanEntryStatus) + ] +} +ALL_COMPLETED = { + "entries": [{**entry, "status": "completed"} for entry in FIRST["entries"]] +} EMPTY = {"entries": []} ONE_COMPLETED = {"entries": [ALL_COMPLETED["entries"][0]]} @@ -33,7 +39,12 @@ async def reveal(source): @asynccontextmanager async def planning_session(workspace, *plans): """The app with the plan peer connected; the prompt of `plans` is sent but the peer waits at each step.""" - async with workspace.app(size=(130, 44), agent=peer("plan", name="Local ACP plan acceptance", identity="plan-acceptance")) as ui: + async with workspace.app( + size=(130, 44), + agent=peer( + "plan", name="Local ACP plan acceptance", identity="plan-acceptance" + ), + ) as ui: view = ui.conversation await ui.until(lambda: view.agent is not None, why="the agent") agent = view.agent @@ -57,48 +68,92 @@ def advance(workspace, index): (workspace.project / f"plan-advance-{index}").touch() -async def test_every_plan_status_is_drawn_with_its_marker_in_the_conversation_and_the_sidebar(workspace): - async with planning_session(workspace, FIRST) as (ui, view, agent, sidebar, sending): - await ui.until(lambda: bool(view.query(Plan)), why="the plan in the conversation") +async def test_every_plan_status_is_drawn_with_its_marker_in_the_conversation_and_the_sidebar( + workspace, +): + async with planning_session(workspace, FIRST) as ( + ui, + view, + agent, + sidebar, + sending, + ): + await ui.until( + lambda: bool(view.query(Plan)), why="the plan in the conversation" + ) plan = view.query_one(Plan) - await ui.until(lambda: len(plan.query(StrikeText)) == len(FIRST["entries"]), why="an item per entry") + await ui.until( + lambda: len(plan.query(StrikeText)) == len(FIRST["entries"]), + why="an item per entry", + ) plan.scroll_visible(animate=False, immediate=True) - await ui.until(lambda: painted_text(plan, "ACP_pending_ITEM"), why="the plan painted") + await ui.until( + lambda: painted_text(plan, "ACP_pending_ITEM"), why="the plan painted" + ) assert not plan.all_complete for entry, raw in zip(plan.entries, FIRST["entries"]): assert entry.status is PlanStatus.member_named(raw["status"]) assert entry.content == raw["content"] assert entry.status.marker().strip() in viewport_text(plan) - await ui.until(lambda: painted_text(sidebar.query_one(Plan), "ACP_pending_ITEM"), why="the sidebar plan painted") + await ui.until( + lambda: painted_text(sidebar.query_one(Plan), "ACP_pending_ITEM"), + why="the sidebar plan painted", + ) assert sidebar.query_one(Plan).entries is plan.entries -async def test_completing_a_plan_animates_the_strike_through_once_and_survives_a_resize(workspace): - async with planning_session(workspace, FIRST, ALL_COMPLETED) as (ui, view, agent, sidebar, sending): +async def test_completing_a_plan_animates_the_strike_through_once_and_survives_a_resize( + workspace, +): + async with planning_session(workspace, FIRST, ALL_COMPLETED) as ( + ui, + view, + agent, + sidebar, + sending, + ): await ui.until(lambda: bool(view.query(Plan)), why="the plan") plan = view.query_one(Plan) - await ui.until(lambda: len(plan.query(StrikeText)) == len(FIRST["entries"]), why="an item per entry") + await ui.until( + lambda: len(plan.query(StrikeText)) == len(FIRST["entries"]), + why="an item per entry", + ) advance(workspace, 0) await ui.until(lambda: plan.all_complete, why="the completed plan") - await ui.until(lambda: len(plan.query(StrikeText)) == len(FIRST["entries"]) - and list(plan.query(StrikeText))[0].strike_time is not None, why="the strike animation") + await ui.until( + lambda: len(plan.query(StrikeText)) == len(FIRST["entries"]) + and list(plan.query(StrikeText))[0].strike_time is not None, + why="the strike animation", + ) completed = list(plan.query(StrikeText)) for raw, text in zip(FIRST["entries"], completed): if raw["status"] == "completed": assert text.has_class("-complete") and text.strike_time is None else: assert text.strike_time is not None - assert len(view.query(Plan)) == 1, "Repeated plan updates appended competing plans" - await ui.until(lambda: all(text.auto_refresh is None for text in completed), why="the animation to stop") + assert ( + len(view.query(Plan)) == 1 + ), "Repeated plan updates appended competing plans" + await ui.until( + lambda: all(text.auto_refresh is None for text in completed), + why="the animation to stop", + ) assert "ACP_pending_ITEM" in viewport_text(plan) assert plan.has_class("-all-complete") await ui.resize(104, 35) plan.scroll_visible(animate=False, immediate=True) - await ui.until(lambda: painted_text(plan, "ACP_pending_ITEM"), why="the plan painted after the resize") + await ui.until( + lambda: painted_text(plan, "ACP_pending_ITEM"), + why="the plan painted after the resize", + ) -async def test_an_empty_plan_resets_the_view_and_a_retired_sidebar_shows_later_plans_again(workspace): - async with planning_session(workspace, FIRST, ALL_COMPLETED, EMPTY, ONE_COMPLETED) as (ui, view, agent, sidebar, sending): +async def test_an_empty_plan_resets_the_view_and_a_retired_sidebar_shows_later_plans_again( + workspace, +): + async with planning_session( + workspace, FIRST, ALL_COMPLETED, EMPTY, ONE_COMPLETED + ) as (ui, view, agent, sidebar, sending): await ui.until(lambda: bool(view.query(Plan)), why="the plan") plan = view.query_one(Plan) advance(workspace, 0) @@ -106,37 +161,74 @@ async def test_an_empty_plan_resets_the_view_and_a_retired_sidebar_shows_later_p advance(workspace, 1) await sidebar.evict() assert not sidebar.panels - await ui.until(lambda: not plan.entries and not plan.all_complete, why="the plan to reset") - await ui.until(lambda: painted_text(plan, "No plan yet"), why="the empty plan painted") + await ui.until( + lambda: not plan.entries and not plan.all_complete, why="the plan to reset" + ) + await ui.until( + lambda: painted_text(plan, "No plan yet"), why="the empty plan painted" + ) await sidebar.prepare_presentation() await reveal(ui.textual[0].selected_session) restored = sidebar.query_one(Plan) assert restored.entries is plan.entries - await ui.until(lambda: painted_text(restored, "No plan yet"), why="the empty plan in the sidebar") + await ui.until( + lambda: painted_text(restored, "No plan yet"), + why="the empty plan in the sidebar", + ) advance(workspace, 2) - await ui.until(lambda: plan.all_complete and len(plan.query(StrikeText)) == 1, why="the one completed item") + await ui.until( + lambda: plan.all_complete and len(plan.query(StrikeText)) == 1, + why="the one completed item", + ) assert plan.query_one(StrikeText).has_class("-complete") assert plan.query_one(StrikeText).strike_time is None - await ui.until(lambda: painted_text(plan, "ACP_pending_ITEM"), why="the plan painted") - await ui.until(lambda: painted_text(sidebar.query_one(Plan), "ACP_pending_ITEM"), why="the sidebar plan painted") + await ui.until( + lambda: painted_text(plan, "ACP_pending_ITEM"), why="the plan painted" + ) + await ui.until( + lambda: painted_text(sidebar.query_one(Plan), "ACP_pending_ITEM"), + why="the sidebar plan painted", + ) async def test_malformed_plan_updates_are_rejected_with_a_visible_note_each(workspace): - async with planning_session(workspace, FIRST) as (ui, view, agent, sidebar, sending): + async with planning_session(workspace, FIRST) as ( + ui, + view, + agent, + sidebar, + sending, + ): await ui.until(lambda: bool(view.query(Plan)), why="the plan") plan = view.query_one(Plan) advance(workspace, 0) await asyncio.wait_for(sending, 10) - await ui.until(lambda: len([note for note in view.query(Note) - if "Invalid ACP update rejected" in note.render().plain]) == 3, - why="three rejection notes") + await ui.until( + lambda: len( + [ + note + for note in view.query(Note) + if "Invalid ACP update rejected" in note.render().plain + ] + ) + == 3, + why="three rejection notes", + ) assert plan.entries[0].content == FIRST["entries"][0]["content"] assert sidebar.query_one(Plan).entries is plan.entries assert ui.failure() is None -async def test_a_plan_sent_while_no_view_shows_the_agent_is_drawn_when_its_view_returns(workspace): - async with planning_session(workspace, FIRST) as (ui, view, agent, sidebar, sending): +async def test_a_plan_sent_while_no_view_shows_the_agent_is_drawn_when_its_view_returns( + workspace, +): + async with planning_session(workspace, FIRST) as ( + ui, + view, + agent, + sidebar, + sending, + ): app = ui.textual[0] await ui.until(lambda: bool(view.query(Plan)), why="the plan") plan = view.query_one(Plan) @@ -147,26 +239,49 @@ async def test_a_plan_sent_while_no_view_shows_the_agent_is_drawn_when_its_view_ editor = view.prompt.prompt_text_area editor.insert("Detached plan draft") original_document, original_history = editor.document, editor.history - await app.session_navigation.new(lambda: MainScreen(workspace.project, agent_session_id="plan-blank")) + await app.session_navigation.new(lambda: MainScreen(workspace.project)) assert agent.controller.surface.target is None - detached_plan = {"entries": [{"content": "DETACHED_PLAN_ITEM", "priority": "high", "status": "pending"}]} + detached_plan = { + "entries": [ + { + "content": "DETACHED_PLAN_ITEM", + "priority": "high", + "status": "pending", + } + ] + } advance(workspace, 0) await asyncio.wait_for(agent.send_prompt(json.dumps([detached_plan])), 10) - await ui.until(lambda: agent.controller.plan_entries is not None - and agent.controller.plan_entries[0].content == "DETACHED_PLAN_ITEM", - why="the plan held by the detached agent") + await ui.until( + lambda: agent.controller.plan_entries is not None + and agent.controller.plan_entries[0].content == "DETACHED_PLAN_ITEM", + why="the plan held by the detached agent", + ) await ui.select_tab(source.id) view = ui.conversation # Earlier plans stay in the conversation as history; the current one is the controller's. - await ui.until(lambda: any(candidate.entries is agent.controller.plan_entries for candidate in view.query(Plan)), - why="the current plan in the returned view") - restored_plan = next(c for c in view.query(Plan) if c.entries is agent.controller.plan_entries) + await ui.until( + lambda: any( + candidate.entries is agent.controller.plan_entries + for candidate in view.query(Plan) + ), + why="the current plan in the returned view", + ) + restored_plan = next( + c for c in view.query(Plan) if c.entries is agent.controller.plan_entries + ) assert restored_plan is not plan assert plan.entries[0].content == "ACP_pending_ITEM" restored_plan.scroll_visible(animate=False, immediate=True) - await ui.until(lambda: painted_text(restored_plan, "DETACHED_PLAN_ITEM"), why="the returned plan painted") + await ui.until( + lambda: painted_text(restored_plan, "DETACHED_PLAN_ITEM"), + why="the returned plan painted", + ) sidebar = await reveal(source) - await ui.until(lambda: painted_text(sidebar.query_one(Plan), "DETACHED_PLAN_ITEM"), why="the sidebar plan painted") + await ui.until( + lambda: painted_text(sidebar.query_one(Plan), "DETACHED_PLAN_ITEM"), + why="the sidebar plan painted", + ) assert restored_plan.entries is agent.controller.plan_entries assert sidebar.query_one(Plan).entries is restored_plan.entries assert view.agent is agent and agent.process.process is original_process diff --git a/tests/textual/test_sidebar_state_across_views.py b/tests/textual/test_sidebar_state_across_views.py index fe93e2d27..9ca25767e 100644 --- a/tests/textual/test_sidebar_state_across_views.py +++ b/tests/textual/test_sidebar_state_across_views.py @@ -2,6 +2,7 @@ from support.stylesheets import INSTALLED from support.textual_steps import channel_roster, coordination_update + from toad.app import ToadApp from toad.screens.main import MainScreen from toad.session_tracker import SidebarSelection @@ -22,23 +23,40 @@ def panel_text(self, screen): if sidebar is None: return () region = sidebar.navigation.scroll_container.content_region - return tuple(strip.text[region.x:region.right] - for strip in screen._compositor.render_strips()[region.y:region.bottom]) + return tuple( + strip.text[region.x : region.right] + for strip in screen._compositor.render_strips()[region.y : region.bottom] + ) def _display(self, screen, renderable): - if (self.panel_frames is not None and renderable is not None and not self._batch_count - and screen is self.screen and screen.query_one_optional(CommsSidebar) is not None): + if ( + self.panel_frames is not None + and renderable is not None + and not self._batch_count + and screen is self.screen + and screen.query_one_optional(CommsSidebar) is not None + ): sidebar = screen.query_one(CommsSidebar) - self.panel_frames.append((self.selected_mode, sidebar.navigation.ready.is_set(), self.panel_text(screen))) + self.panel_frames.append( + ( + self.selected_mode, + sidebar.navigation.ready.is_set(), + self.panel_text(screen), + ) + ) return super()._display(screen, renderable) def group(view, name): - return next(item for item in view.query(ChannelGroup) if item.row.target_name == name) + return next( + item for item in view.query(ChannelGroup) if item.row.target_name == name + ) async def settled(ui, view): - await ui.until(view.navigation.ready.is_set, seconds=5, why="the sidebar to be ready") + await ui.until( + view.navigation.ready.is_set, seconds=5, why="the sidebar to be ready" + ) await ui.step() @@ -54,9 +72,15 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac await ui.step() owner = ui.selected_tab worker = await app.session_navigation.new( - lambda: MainScreen(workspace.project, agent_session_id="worker")) + lambda: MainScreen.from_thread( + workspace.project, + str(workspace.comms.root), + workspace.comms.registry.require("worker").incarnation, + ) + ) await app.selected_session.on_coordination_update( - coordination_update(str(workspace.roots.wire), "worker")) + coordination_update(str(workspace.roots.wire), "worker") + ) await ui.select_tab(owner) sidebar = await channel_roster(ui, "#any", "#channel-28") @@ -70,8 +94,16 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac await pilot.wait_for_scheduled_animations() expected_scroll = sidebar.navigation.scroll_container.scroll_y assert expected_scroll > 0, [ - (type(widget).__name__, widget.size, widget.virtual_size, widget.scroll_y, widget.max_scroll_y) - for widget in (sidebar, *sidebar.ancestors) if hasattr(widget, "virtual_size")] + ( + type(widget).__name__, + widget.size, + widget.virtual_size, + widget.scroll_y, + widget.max_scroll_y, + ) + for widget in (sidebar, *sidebar.ancestors) + if hasattr(widget, "virtual_size") + ] expected_frame = app.panel_text(app.screen) app.panel_frames = [] selected.row.action_open_selected() @@ -80,16 +112,28 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac assert channel_mode != owner current = app.screen.query_one(CommsSidebar) await settled(ui, current) - assert app.panel_frames and any(mode == channel_mode and ready for mode, ready, _ in app.panel_frames) - assert all(frame == expected_frame for _, ready, frame in app.panel_frames if ready), app.panel_frames + assert app.panel_frames and any( + mode == channel_mode and ready for mode, ready, _ in app.panel_frames + ) + assert all( + frame == expected_frame for _, ready, frame in app.panel_frames if ready + ), app.panel_frames app.panel_frames = None assert group(current, "#channel-28").expanded assert not group(current, "#any").expanded actual_scroll = current.navigation.scroll_container.scroll_y assert actual_scroll == expected_scroll, ( - actual_scroll, expected_scroll, app.sidebar_state, - (current.navigation.scroll_container.scroll_y, current.navigation.scroll_container.max_scroll_y)) - assert app.sidebar_state.selected == SidebarSelection("#channel-28", "#channel-28") + actual_scroll, + expected_scroll, + app.sidebar_state, + ( + current.navigation.scroll_container.scroll_y, + current.navigation.scroll_container.max_scroll_y, + ), + ) + assert app.sidebar_state.selected == SidebarSelection( + "#channel-28", "#channel-28" + ) group(current, "#channel-28").toggle_members() await ui.step() await pilot.wait_for_scheduled_animations() @@ -100,7 +144,9 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac assert not group(sidebar, "#any").expanded actual_scroll = sidebar.navigation.scroll_container.scroll_y assert actual_scroll == expected_scroll, (actual_scroll, expected_scroll) - assert app.sidebar_state.selected == SidebarSelection("#channel-28", "#channel-28") + assert app.sidebar_state.selected == SidebarSelection( + "#channel-28", "#channel-28" + ) nested = group(sidebar, "#channel-28") nested.toggle_members() await ui.step() @@ -117,12 +163,21 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac app.panel_frames = [] await ui.until( lambda: ui.selected_tab == worker.mode_name - and any(mode == worker.mode_name and ready for mode, ready, _ in app.panel_frames), - seconds=5, why="the worker view's ready sidebar to be drawn") + and any( + mode == worker.mode_name and ready + for mode, ready, _ in app.panel_frames + ), + seconds=5, + why="the worker view's ready sidebar to be drawn", + ) current = app.screen.query_one(CommsSidebar) await settled(ui, current) - assert app.panel_frames and any(mode == worker.mode_name and ready for mode, ready, _ in app.panel_frames) - assert all(frame == expected_frame for _, ready, frame in app.panel_frames if ready), app.panel_frames + assert app.panel_frames and any( + mode == worker.mode_name and ready for mode, ready, _ in app.panel_frames + ) + assert all( + frame == expected_frame for _, ready, frame in app.panel_frames if ready + ), app.panel_frames app.panel_frames = None assert ui.selected_tab == worker.mode_name assert group(current, "#channel-28").expanded @@ -133,8 +188,12 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac assert not any(row.has_class("-selected") for row in current._ordered_rows()) await pilot.hover(member) assert "hover" in member.pseudo_classes and not member.has_focus, ( - member.region, member.pseudo_classes, member.has_focus, - app.get_widget_at(member.region.x, member.region.y), current.navigation.scroll_container.region) + member.region, + member.pseudo_classes, + member.has_focus, + app.get_widget_at(member.region.x, member.region.y), + current.navigation.scroll_container.region, + ) await pilot.hover(ui.conversation.prompt) assert "hover" not in member.pseudo_classes and member.current assert current.navigation.scroll_container.scroll_y == expected_scroll diff --git a/tests/textual/test_workspace_tabs_share_one_agent_connection.py b/tests/textual/test_workspace_tabs_share_one_agent_connection.py index 120c51d3f..ef5b6bddb 100644 --- a/tests/textual/test_workspace_tabs_share_one_agent_connection.py +++ b/tests/textual/test_workspace_tabs_share_one_agent_connection.py @@ -4,8 +4,10 @@ from functools import partial from support.shared_stdio import stdio_agent_definition, threads_owned_here + from toad.app import ToadApp from toad.screens.main import MainScreen +from toad.session_scope import AgentSession async def ready(agent): @@ -14,25 +16,39 @@ async def ready(agent): assert agent.ready -async def test_a_second_tab_reuses_the_first_tabs_agent_process_and_closing_it_keeps_the_process(workspace, monkeypatch): +async def test_a_second_tab_reuses_the_first_tabs_agent_process_and_closing_it_keeps_the_process( + workspace, monkeypatch +): # The agent's model catalogue comes from this variable instead of a native package. - monkeypatch.setenv('AGENT_COMMS_AGENT_MODELS', 'test/one') - project, comms, definition = workspace.project, workspace.comms, stdio_agent_definition() + monkeypatch.setenv("AGENT_COMMS_AGENT_MODELS", "test/one") + project, comms, definition = ( + workspace.project, + workspace.comms, + stdio_agent_definition(), + ) async with threads_owned_here(comms, project): - application = partial(ToadApp, agent_session_id='alpha') - async with workspace.app(size=(110, 36), agent=definition, application=application) as ui: + application = partial(ToadApp, agent_session_id="alpha") + async with workspace.app( + size=(110, 36), agent=definition, application=application + ) as ui: app, _pilot = ui.textual - await ui.until(lambda: app.selected_session.presentation.operational_agent is not None, - why="the first tab's agent") + await ui.until( + lambda: app.selected_session.presentation.operational_agent is not None, + why="the first tab's agent", + ) source = app.selected_session first = source.presentation.operational_agent await ready(first) connection = first.process owner_tab = ui.selected_tab - details = await app.session_navigation.new(lambda: MainScreen(project, definition, 'beta')) + details = await app.session_navigation.new( + lambda: MainScreen(project, AgentSession(definition, "beta")) + ) second_source = app.workspace_sessions.require(details.mode_name) - await ui.until(lambda: second_source.presentation.operational_agent is not None, - why="the second tab's agent") + await ui.until( + lambda: second_source.presentation.operational_agent is not None, + why="the second tab's agent", + ) second = second_source.presentation.operational_agent await ready(second) assert second.process is connection From 77a224f8e00e5dd8cb5ce06dd42b0d7aa4ae3612 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:40:18 -0400 Subject: [PATCH 17/43] Keep delivery settlement out of the prompt draft --- src/toad/conversation_comms_handlers.py | 13 +- src/toad/conversation_submission.py | 121 ++++++++---- src/toad/delivery_failure_view.py | 73 ++++--- src/toad/widgets/prompt.py | 244 +++++++++++++++--------- tests/app/test_agent_binding.py | 4 +- tests/textual/test_delivery_draft.py | 33 ++++ tests/textual/test_interrupted_send.py | 104 ---------- 7 files changed, 337 insertions(+), 255 deletions(-) create mode 100644 tests/textual/test_delivery_draft.py delete mode 100644 tests/textual/test_interrupted_send.py diff --git a/src/toad/conversation_comms_handlers.py b/src/toad/conversation_comms_handlers.py index 4bfb8f098..1b70a0670 100644 --- a/src/toad/conversation_comms_handlers.py +++ b/src/toad/conversation_comms_handlers.py @@ -1,8 +1,7 @@ """Conversation comms behavior on the existing receiver MRO.""" from __future__ import annotations -from toad.core_event_carrier import CoreEventReceiver -from toad.core_event_carrier import CoreEventMessage + import agent_comms.agent_events as comms_events from agent_comms.acp_extension import ( CompactionChangedUpdate, @@ -10,18 +9,20 @@ GoalChangedUpdate, InputFailedUpdate, InputStartedUpdate, - PromptCancelledUpdate, McpClientAcknowledgementUpdate, + PromptCancelledUpdate, TranscriptChangedUpdate, TranscriptSnapshotUpdate, TurnChangedUpdate, ) from agent_comms.mro_dispatch import handles from textual.content import Content -from toad.core import events as core_events + from toad.acp.attachment_presentation import CursorPresentation, QueuePresentation -from toad.widgets.note import Note +from toad.core import events as core_events +from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.widgets.markdown_note import MarkdownNote +from toad.widgets.note import Note class ConversationCommsHandlers(CoreEventReceiver): @@ -111,7 +112,7 @@ def on_input_failed( style="error", ) return - self.submissions.restore_draft(message.event.update.text) + self.restore_user_message(message.event.update.text) self.flash( f"{message.event.update.failure.title}; draft restored: {message.event.update.failure.description}\n{message.event.update.failure.input_disposition}\n{message.event.update.failure.action}", diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 939361869..41b7ff59b 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -1,4 +1,5 @@ """One submission transaction owns input intent, request identity and draft recovery.""" + from __future__ import annotations import asyncio @@ -7,14 +8,17 @@ from functools import partial from typing import ClassVar -from agent_comms.acp_extension import InputStartedUpdate, QueuePromptRequest, SteerPromptRequest -from agent_comms.declared_family import DeclaredFamily -from toad import messages from acp.exceptions import RequestError +from agent_comms.acp_extension import ( + InputStartedUpdate, + QueuePromptRequest, + SteerPromptRequest, +) +from agent_comms.declared_family import DeclaredFamily + from toad.acp.client_session import ClientSessionRequest -from agent_comms.acp_extension import PendingQueueProjection -from toad.widgets.prompt import Prompt from toad.command_catalog import CommandCatalog +from toad.widgets.prompt import Prompt @dataclass(frozen=True) @@ -35,39 +39,51 @@ async def execute(self, owner): ... @classmethod def from_event(cls, event, view): - kind = next(kind for kind in sorted(cls.members_with(InputSubmission), - key=lambda kind: kind.priority, reverse=True) - if kind.matches(event, view)) + kind = next( + kind + for kind in sorted( + cls.members_with(InputSubmission), + key=lambda kind: kind.priority, + reverse=True, + ) + if kind.matches(event, view) + ) return kind(event.body) class NoInputSubmission(InputSubmission): priority = -1 + @classmethod def matches(cls, event, view): return True + async def execute(self, owner): pass class QueueNowInputSubmission(InputSubmission): priority = 100 + @classmethod def matches(cls, event, view): if event.shell or event.body.strip(): return False return event.immediate and view.agent_binding.presentation.queue_supported + async def execute(self, owner): projection = owner.queue_projection - if projection.status == 'available' and projection.items: + if projection.status == "available" and projection.items: owner.send_now() class ShellInputSubmission(InputSubmission): priority = 90 + @classmethod def matches(cls, event, view): return event.shell and bool(event.body.strip()) + async def execute(self, owner): view = owner.view shell = view.shell @@ -75,7 +91,10 @@ async def execute(self, owner): shell.focus_output() await shell.send_input(self.source_text, paste=True) else: - view.run_worker(partial(view.input_histories.shell.record, self.source_text), group='history') + view.run_worker( + partial(view.input_histories.shell.record, self.source_text), + group="history", + ) await view.post_shell(self.source_text) view.jump_to_latest() @@ -93,7 +112,9 @@ def __init__(self, owner, submission): async def prepare(self): self.owner.view.delivery_refused( - "No connected owner; your input has not been submitted", self.submission.source_text) + "No connected owner; your input has not been submitted", + self.submission.source_text, + ) class AgentInputSubmission(InputSubmission): @@ -110,12 +131,15 @@ async def feedback(self, execution): await view.auto_name_from_prompt(self.text) if execution.retired: return - waiting = 'Waiting for replies…' if self.text.startswith(('@', '#', '!relay ')) else 'Thinking…' + waiting = ( + "Waiting for replies…" + if self.text.startswith(("@", "#", "!relay ")) + else "Thinking…" + ) view.turns.describe(waiting) await asyncio.sleep(0) async def execute(self, owner): - view = owner.view return await owner.begin(self).prepare() @@ -125,6 +149,7 @@ def matches(cls, event, view): if event.shell: return False return bool(event.body.strip()) + @property def request(self): return QueuePromptRequest(self.text) @@ -132,6 +157,7 @@ def request(self): class DeferredInputSubmission(OrdinaryInputSubmission): priority = 70 + @classmethod def matches(cls, event, view): if not super().matches(event, view): @@ -139,9 +165,11 @@ def matches(cls, event, view): if event.immediate: return False return view.turns.owner.busy and view.agent_binding.presentation.queue_supported + @property def request(self): return QueuePromptRequest(self.text, True) + async def feedback(self, execution): # Native queue/start publications own confirmation and message display. pass @@ -149,17 +177,21 @@ async def feedback(self, execution): class ImmediateInputSubmission(OrdinaryInputSubmission): priority = 80 + @classmethod def matches(cls, event, view): if not super().matches(event, view): return False return event.immediate and view.agent_binding.presentation.queue_supported + @property def request(self): return SteerPromptRequest(self.text) + class SubmissionExecution(SubmissionStart): """Immutable source binding and the real outstanding local request.""" + def __init__(self, owner, submission, agent): self.owner, self.submission, self.agent = owner, submission, agent self.authority = ClientSessionRequest(agent, agent.session_id) @@ -171,11 +203,16 @@ async def prepare(self): owner, submission = self.owner, self.submission view = owner.view owner.publish_pending() - view.run_worker(partial(view.input_histories.prompt.record, submission.source_text), group='history') + view.run_worker( + partial(view.input_histories.prompt.record, submission.source_text), + group="history", + ) try: await submission.feedback(self) if self.current: - return view.run_worker(partial(self.send), group='conversation-submission') + return view.run_worker( + partial(self.send), group="conversation-submission" + ) owner.finish(self) except BaseException: owner.finish(self) @@ -201,17 +238,26 @@ async def send(self): view.turns.start_client() try: if self.agent.presentation.queue_supported: - reason = await self.agent.send_prompt(self.submission.text, request=self.request) + reason = await self.agent.send_prompt( + self.submission.text, request=self.request + ) else: reason = await self.agent.send_prompt(self.submission.text) except RequestError as error: if self.current: from toad.widgets.conversation import INTERNAL_EROR from toad.widgets.markdown_note import MarkdownNote + view.turns.finish_client() - self.owner.restore_draft(self.submission.text) - await view.post(MarkdownNote(INTERNAL_EROR.replace('$ERROR', str(error) or 'no details were provided'), - classes='-stop-reason')) + view.restore_user_message(self.submission.text) + await view.post( + MarkdownNote( + INTERNAL_EROR.replace( + "$ERROR", str(error) or "no details were provided" + ), + classes="-stop-reason", + ) + ) finally: self.owner.finish(self) if self.current and local: @@ -224,6 +270,7 @@ async def complete(self, reason): class ConversationSubmissions: """Own pending local transactions; remote membership stays with QueueAttachment.""" + def __init__(self, view): self.view = view self.active: list[SubmissionExecution] = [] @@ -249,7 +296,11 @@ def unavailable_reason(self): async def submit(self, event): text = event.body.strip() - if not event.shell and text.startswith('/') and await CommandCatalog.execute(text, self.view): + if ( + not event.shell + and text.startswith("/") + and await CommandCatalog.execute(text, self.view) + ): return return await self.view.operational_sources.submit(self, event) @@ -269,10 +320,14 @@ def queued_inputs(self): def delivering(self): queue = self.queue_projection accepted = {row.input_id for row in (*queue.items, *queue.restored)} - return tuple(item.submission.text for item in self.active - if item.current and item.agent.presentation.uses_managed_turns - and item.request.input_id not in accepted - and item.started_update is None) + return tuple( + item.submission.text + for item in self.active + if item.current + and item.agent.presentation.uses_managed_turns + and item.request.input_id not in accepted + and item.started_update is None + ) def native_input_presented(self, started: InputStartedUpdate): """Retain this request's actual response while its RPC is outstanding.""" @@ -294,18 +349,16 @@ def reset(self): self.active.clear() self.publish_pending() - def restore_draft(self, text): - current = self.view.prompt.text - if text and current != text and not current.endswith('\n\n' + text): - self.view.prompt.text = '\n\n'.join(filter(None, [current, text])) - def send_now(self): return self.view.agent_binding.send_now(self) def send_bound_now(self, agent): session, scope = agent.session_id, agent.queue_attachment.scope - return self.view.run_worker(partial(self._send_now, agent, session, scope), - group='send-queued-now', exclusive=True) + return self.view.run_worker( + partial(self._send_now, agent, session, scope), + group="send-queued-now", + exclusive=True, + ) async def _send_now(self, agent, session, scope): try: @@ -313,6 +366,8 @@ async def _send_now(self, agent, session, scope): return except RequestError as error: if self.view.agent_binding is agent and agent.session_id == session: - self.view.flash(f'Send now failed: {error}', style='error') - if self.view.agent_binding is agent and agent.queue_attachment.accepts_request(scope): + self.view.flash(f"Send now failed: {error}", style="error") + if self.view.agent_binding is agent and agent.queue_attachment.accepts_request( + scope + ): self.publish_pending() diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index 93597b9e5..e25f2d94d 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -1,9 +1,9 @@ """The concrete compose controls used by shared delivery-failure declarations.""" - import asyncio from dataclasses import dataclass -from agent_comms.errors import HumanInitialUnknownError, HumanClaimBlockedError + +from agent_comms.errors import HumanClaimBlockedError, HumanInitialUnknownError class SendState: @@ -53,8 +53,10 @@ class SendUnknown(SendState): @property def reason(self) -> str: failure = self.failure - return (f"Send UNKNOWN {failure.wire_root_id}/{failure.wire_seq}/{failure.message_id}: " - f"{failure.description}; inspect the bus, do not retry") + return ( + f"Send UNKNOWN {failure.wire_root_id}/{failure.wire_seq}/{failure.message_id}: " + f"{failure.description}; inspect the bus, do not retry" + ) class DeliveryFailureView: @@ -69,11 +71,11 @@ def send_block_reason(self) -> str: """Why this view's own send holds compose; empty when it does not.""" return self._send_state.reason - def _enter_send_state(self, state: SendState, body: str) -> None: - """The one place a send changes state; compose and the status line follow it.""" + def _enter_send_state(self, state: SendState) -> None: + """Delivery changes compose availability, never the user's draft.""" self._send_state = state - self.prompt.text = body if self.is_attached: + async def refresh() -> None: read_only = await self.composer_read_only() await self.refresh_composer(read_only) @@ -97,23 +99,28 @@ async def submit_comms_message(self, root, conversation, body, current, present) if not body.strip(): return async with self.app.coordination_access.transcripts.bind(root) as comms: - read_only = await self.app.preparation.run_thread(conversation.read_only, comms) + read_only = await self.app.preparation.run_thread( + conversation.read_only, comms + ) await self.refresh_composer(read_only) if read_only: - self.prompt.text = body + self.restore_user_message(body) self.flash(self.status, style="error") return - await self.deliver_user_message(root, conversation.send_target().name, body, current, present) + await self.deliver_user_message( + root, conversation.send_target().name, body, current, present + ) async def deliver_user_message(self, root, target, body, current, present): """One route check, write and failure path for CLI, DM and channel input.""" - from agent_comms.route_selection import root_is_current from agent_comms.delivery_failure import delivery_failure + from agent_comms.route_selection import root_is_current if self.send_block_reason: + self.restore_user_message(body) self.flash(self.send_block_reason, style="error") return - self._enter_send_state(SendPending(), body) + self._enter_send_state(SendPending()) async def require_route(): route_current = await self.app.preparation.run_thread(root_is_current, root) @@ -135,37 +142,57 @@ async def require_route(): await present(sent) async def commit_user_message(self, comms, target, body): - from agent_comms.route_selection import run_selected_write from agent_comms.delivery_failure import delivery_failure + from agent_comms.route_selection import run_selected_write - task = asyncio.create_task(asyncio.to_thread( - run_selected_write, comms.root, comms.messaging.send_user_message, - target, body, worktree=str(self.project_path))) + task = asyncio.create_task( + asyncio.to_thread( + run_selected_write, + comms.root, + comms.messaging.send_user_message, + target, + body, + worktree=str(self.project_path), + ) + ) try: sent = await asyncio.shield(task) except asyncio.CancelledError: - self.delivery_blocked("Send cancelled while worker may still write; outcome UNKNOWN, inspect the bus, do not retry", body) - task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None) + self.delivery_blocked( + "Send cancelled while worker may still write; outcome UNKNOWN, inspect the bus, do not retry", + body, + ) + task.add_done_callback( + lambda done: done.exception() if not done.cancelled() else None + ) return None except (HumanInitialUnknownError, HumanClaimBlockedError) as error: delivery_failure(error).present(self, body) return None - self._enter_send_state(SentUndrawn(comms.root, sent), body) + self._enter_send_state(SentUndrawn(comms.root, sent)) return sent def settle_user_message(self) -> None: """Enable compose only when the sent message is drawn.""" if self._send_state.drawn_in(self): - self._enter_send_state(NoSend(), "") + self._enter_send_state(NoSend()) + + def restore_user_message(self, body: str) -> None: + """Give this failed send back only while no newer draft owns the input.""" + if not self.prompt.text: + self.prompt.text = body def delivery_unknown(self, failure, body: str) -> None: - self._enter_send_state(SendUnknown(failure), body) + self.restore_user_message(body) + self._enter_send_state(SendUnknown(failure)) self.flash(self.send_block_reason, style="error") def delivery_blocked(self, description: str, body: str) -> None: - self._enter_send_state(SendBlocked(description), body) + self.restore_user_message(body) + self._enter_send_state(SendBlocked(description)) self.flash(self.send_block_reason, style="error") def delivery_refused(self, description: str, body: str) -> None: - self._enter_send_state(NoSend(), body) + self.restore_user_message(body) + self._enter_send_state(NoSend()) self.flash(f"Send failed: {description}", style="error") diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 074ce91b9..39eef7a54 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -1,71 +1,79 @@ from __future__ import annotations -from toad.core import input_events -from toad.conversation_turn import ConversationTurn -from pathlib import Path -from dataclasses import replace -from textual._paint_state import PaintState -from textual.style import Style -from textual.strip import Strip -from rich.text import Text as RichText +import asyncio import shlex +from dataclasses import replace +from pathlib import Path from typing import TYPE_CHECKING, ClassVar, Literal, Self -from textual import on, work -import asyncio -from textual.reactive import var, Initialize -from textual.app import ComposeResult - +from rich.text import Text as RichText +from textual import containers, events, getters, on, work +from textual._activation import PrimaryActivation +from textual._paint_state import PaintState from textual.actions import SkipAction +from textual.app import ComposeResult from textual.binding import Binding - +from textual.color import TRANSPARENT from textual.content import Content, Span -from toad.agent_binding import NoAgentBound -from textual import getters -from textual.widgets import OptionList, TextArea, Label -from textual import containers -from textual._activation import PrimaryActivation -from textual.widget import Widget +from textual.css._style_properties import Edges +from textual.css.scalar import Scalar +from textual.geometry import Spacing +from textual.reactive import Initialize, var +from textual.strip import Strip +from textual.style import Style +from textual.widgets import Label, OptionList, TextArea from textual.widgets.option_list import Option from textual.widgets.text_area import Selection -from textual import events +from ui_theme.scheme import PrimaryColour, PromptToneColour, SecondaryColour -from textual.geometry import Spacing -from textual.color import TRANSPARENT -from textual.css._style_properties import Edges -from toad.widget_view import AUTO, FocusWithinView, WidgetView -from toad.setting_widget import SettingVisibility -from textual.css.scalar import Scalar from toad import messages -from toad.widgets.highlighted_textarea import HighlightedTextArea -from toad.widgets.condensed_path import CondensedPath -from toad.widgets.path_search import PathSearch -from toad.widgets.question import Ask, Question -from toad.widgets.slash_complete import SlashComplete -from toad.widgets.model_switcher import ModelSwitcher -from toad.widgets.prompt_popup import PromptPopup, CompletionPopup, InfoPopup +from toad.agent_binding import NoAgentBound +from toad.conversation_turn import ConversationTurn +from toad.core import input_events from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventReceiver -from toad.slash_command import SlashCommand -from toad.path_complete import PathComplete -from toad.prompt_cursor import CommandText, PromptCursor, PreviousHistoryCursor, NextHistoryCursor -from toad.widgets.selection import ChangingText, SelectionOptionList, InlineSelection -from toad.widget_actions import DeclaredWidgetActions from toad.frame_presentation import TonePaint -from ui_theme.scheme import PrimaryColour, SecondaryColour, PromptToneColour +from toad.path_complete import PathComplete from toad.prompt_actions import PromptAction, SubmitNowAction +from toad.prompt_cursor import ( + CommandText, + NextHistoryCursor, + PreviousHistoryCursor, + PromptCursor, +) +from toad.setting_widget import SettingVisibility +from toad.slash_command import SlashCommand +from toad.widget_actions import DeclaredWidgetActions +from toad.widget_view import AUTO, FocusWithinView, WidgetView +from toad.widgets.condensed_path import CondensedPath +from toad.widgets.highlighted_textarea import HighlightedTextArea +from toad.widgets.model_switcher import ModelSwitcher +from toad.widgets.path_search import PathSearch +from toad.widgets.prompt_popup import CompletionPopup, InfoPopup, PromptPopup +from toad.widgets.question import Ask, Question +from toad.widgets.selection import ChangingText, InlineSelection, SelectionOptionList +from toad.widgets.slash_complete import SlashComplete if TYPE_CHECKING: from toad.app import ToadApp - from toad.agent import AgentBase class ModeSwitcher(CoreEventReceiver, SelectionOptionList): - def _get_option_render(self, option: Option, style: Style, paint: PaintState) -> list[Strip]: - if self.has_focus and self.highlighted is not None and option is self.options[self.highlighted]: - background = paint.background + self.app.colours.colour(PrimaryColour).with_alpha(0.3).multiply_alpha(paint.opacity) + def _get_option_render( + self, option: Option, style: Style, paint: PaintState + ) -> list[Strip]: + if ( + self.has_focus + and self.highlighted is not None + and option is self.options[self.highlighted] + ): + background = paint.background + self.app.colours.colour( + PrimaryColour + ).with_alpha(0.3).multiply_alpha(paint.opacity) base = paint.component_style(self, "option-list--option") - style = replace(base, foreground=style.foreground, background=background, bold=False) + style = replace( + base, foreground=style.foreground, background=background, bold=False + ) return super()._get_option_render(option, style, paint) BINDING_GROUP_TITLE = "Mode switcher" @@ -80,7 +88,9 @@ def action_dismiss(self): self.blur() -class AgentInfo(SettingVisibility, WidgetView, PrimaryActivation, Label, setting="ui.agent_title"): +class AgentInfo( + SettingVisibility, WidgetView, PrimaryActivation, Label, setting="ui.agent_title" +): width = AUTO # Any agent state, connected or not, leaves the status line its room. max_width = Scalar.parse("60%") @@ -88,7 +98,11 @@ class AgentInfo(SettingVisibility, WidgetView, PrimaryActivation, Label, setting @property def padding(self): - return super().padding if self.app.settings.ui.compact_input else Spacing(0, 1, 0, 1) + return ( + super().padding + if self.app.settings.ui.compact_input + else Spacing(0, 1, 0, 1) + ) @property def primary_activation(self): @@ -145,7 +159,9 @@ def on_click(self, event: events.Click) -> None: self.action_edit_queue() -class InfoBar(SettingVisibility, WidgetView, containers.HorizontalGroup, setting="ui.info_bar"): +class InfoBar( + SettingVisibility, WidgetView, containers.HorizontalGroup, setting="ui.info_bar" +): height = Scalar.parse("1") @property @@ -180,7 +196,9 @@ def border(self): return super().border else: colour = PrimaryColour if prompt.shell_mode else SecondaryColour - paint = self.app.colours.colour(colour) if self.has_focus_within else TRANSPARENT + paint = ( + self.app.colours.colour(colour) if self.has_focus_within else TRANSPARENT + ) return Edges(*(("tall", paint),) * 4) def on_mouse_down(self, event: events.MouseUp) -> None: @@ -199,7 +217,9 @@ def schedule_submission(self, *, immediate: bool = False) -> None: self.call_later(self._submit) -class PromptTextArea(CoreEventReceiver, DeclaredWidgetActions, PromptSubmission, HighlightedTextArea): +class PromptTextArea( + CoreEventReceiver, DeclaredWidgetActions, PromptSubmission, HighlightedTextArea +): ACTIONS = PromptAction # Agent prompts and direct messages carry a pasted image as a file reference. ATTACHES_IMAGES: ClassVar[bool] = True @@ -217,8 +237,12 @@ class PromptTextArea(CoreEventReceiver, DeclaredWidgetActions, PromptSubmission, BINDING_GROUP_TITLE = "Prompt" BINDINGS = [ - Binding("pageup", "history_page_up", "Previous messages", priority=True, show=False), - Binding("pagedown", "history_page_down", "Next messages", priority=True, show=False), + Binding( + "pageup", "history_page_up", "Previous messages", priority=True, show=False + ), + Binding( + "pagedown", "history_page_down", "Next messages", priority=True, show=False + ), Binding("ctrl+v", "paste_clipboard", "Paste", priority=True, show=False), Binding("ctrl+j", "newline", "New line", priority=True, show=False), Binding( @@ -309,16 +333,21 @@ def show_editing_mode(self, mode) -> None: def apply_keyboard_profile(self) -> None: from textual.widgets.text_area import EditingMode, VimInsert + enabled = self.app.settings.ui.vim_keys if self.editing_mode.modal != enabled: self.editing_mode = VimInsert() if enabled else EditingMode() @property def submission_allowed(self) -> bool: - return self.editing_mode.accepts_text and not self.query_ancestor(Prompt).active_popup + return ( + self.editing_mode.accepts_text + and not self.query_ancestor(Prompt).active_popup + ) def leave_modal_editor(self) -> None: from toad.widgets.conversation import Conversation + self.query_ancestor(Conversation).window.focus(scroll_visible=False) def on_key(self, event: events.Key) -> None: @@ -360,24 +389,34 @@ async def action_paste_clipboard(self) -> None: return if image is not None: if self.shell_mode or not self.ATTACHES_IMAGES: - self.notify("Paste images into an agent thread, not the IRC or shell composer.", - title="Image attachment", severity="warning") + self.notify( + "Paste images into an agent thread, not the IRC or shell composer.", + title="Image attachment", + severity="warning", + ) return path = await asyncio.to_thread(save_clipboard_png, image) - reference = f'@"{path}"' if any(char.isspace() for char in str(path)) else f"@{path}" + reference = ( + f'@"{path}"' + if any(char.isspace() for char in str(path)) + else f"@{path}" + ) self._paste_clipboard_text(" " + reference + " ") - self.notify(f"PNG attached ({len(image):,} bytes). Enter sends it with your prompt.", - title="Image attachment") + self.notify( + f"PNG attached ({len(image):,} bytes). Enter sends it with your prompt.", + title="Image attachment", + ) return text = (await self.app.clipboard_transport.paste(self.app)).text if text: self._paste_clipboard_text(text) else: - self.notify("No text or supported image in the clipboard", title="Paste") + self.notify( + "No text or supported image in the clipboard", title="Paste" + ) except (OSError, ValueError, TimeoutError) as error: self.app.show_error(error, "Paste failed") - def _submit(self) -> None: self._submit_pending = False immediate, self._submit_immediate = self._submit_immediate, False @@ -389,7 +428,9 @@ def _submit(self) -> None: self.app.bell() self.post_message( messages.Flash( - self.query_ancestor(Prompt).operational_submissions.unavailable_reason, + self.query_ancestor( + Prompt + ).operational_submissions.unavailable_reason, "error", ) ) @@ -408,8 +449,9 @@ def _submit(self) -> None: self.suggestion = "" return shell = self.shell_mode - self.publish_core(UserInputSubmitted(self.text, shell, immediate=immediate)) + text = self.text self.clear() + self.publish_core(UserInputSubmitted(text, shell, immediate=immediate)) if shell: self.query_ancestor(Prompt).shell_mode = False @@ -466,10 +508,7 @@ async def action_tab_complete(self) -> None: command = shlex_tokens[0] exclude_node_type: Literal["file"] | Literal["dir"] | None = None - if ( - command - in self.app.settings.shell.directory_commands.splitlines() - ): + if command in self.app.settings.shell.directory_commands.splitlines(): exclude_node_type = "file" elif command in self.app.settings.shell.file_commands.splitlines(): exclude_node_type = "dir" @@ -507,7 +546,9 @@ async def action_tab_complete(self) -> None: ) self.suggestion = self.suggestions[self.suggestions_index] - def watch_selection(self, previous_selection: Selection, selection: Selection) -> None: + def watch_selection( + self, previous_selection: Selection, selection: Selection + ) -> None: if self.is_mounted: self.input_cursor.changed(previous_selection, selection) @@ -619,16 +660,24 @@ def sync_configuration(self) -> None: self.set_class(mode is not None, "-has-mode") info = self.query_one(ModeInfo) info.update(mode.name if mode is not None else "") - info.tooltip = (Content.from_markup( - "[b]$description[/]\n\n[dim](click to open mode switcher)", - description=mode.description or mode.name, - ) if mode is not None else None) + info.tooltip = ( + Content.from_markup( + "[b]$description[/]\n\n[dim](click to open mode switcher)", + description=mode.description or mode.name, + ) + if mode is not None + else None + ) model = agent.configuration.model.selected self.set_class(model is not None, "-has-model") - self.query_one(AgentInfo).tooltip = (Content.from_markup( - "[b]$description[/]\n\n[dim](click to search models)", - description=model.description or model.value, - ) if model is not None else None) + self.query_one(AgentInfo).tooltip = ( + Content.from_markup( + "[b]$description[/]\n\n[dim](click to search models)", + description=model.description or model.value, + ) + if model is not None + else None + ) self.update_modes() self.model_switcher.configuration_changed() @@ -647,7 +696,9 @@ def remove_ask(self, ask: Ask) -> None: if self._ask is ask: self._ask = self.ask_queue.pop(0) if self.ask_queue else None else: - self.ask_queue = [pending for pending in self.ask_queue if pending is not ask] + self.ask_queue = [ + pending for pending in self.ask_queue if pending is not ask + ] @on(events.Click, "ModeInfo") def on_mode_info_click(self): @@ -675,7 +726,9 @@ def update_modes(self) -> None: [Option(row, id=mode.value) for row, mode in zip(columns, mode_list)] ) if current is not None: - self.mode_switcher.highlighted = self.mode_switcher.get_option_index(current.value) + self.mode_switcher.highlighted = self.mode_switcher.get_option_index( + current.value + ) def sync_turn(self) -> None: if not self.is_mounted: @@ -684,6 +737,7 @@ def sync_turn(self) -> None: self.set_class(SubmitNowAction().available(editor), "-queue-mode") editor.refresh_bindings() from toad.widgets.conversation import Conversation + self.query_ancestor(Conversation).goal_bar._queue_separator_update() def sync_queue(self) -> None: @@ -707,11 +761,16 @@ def _update_queue_summary(self) -> None: parts: list[str] = [] if feedback: parts.append(feedback) - for label, texts, detail in (("Submitting", delivering, ""), ("Queued", queued, ""), - ("Restored", restored, ", read-only")): + for label, texts, detail in ( + ("Submitting", delivering, ""), + ("Queued", queued, ""), + ("Restored", restored, ", read-only"), + ): if texts: - parts.append(f"{label} ({len(texts)}{detail}): " - + " · ".join(" ".join(text.split())[:100] for text in texts[:3])) + parts.append( + f"{label} ({len(texts)}{detail}): " + + " · ".join(" ".join(text.split())[:100] for text in texts[:3]) + ) summary = self.query_one(".queue-summary", Label) summary.tooltip = "Submitting is an outstanding local request, not acceptance. Queued is the producer's accepted input, not consumption or completion. Restored rows are not local drafts." summary.update(Content(" | ".join(parts))) @@ -737,7 +796,9 @@ def watch_agent_info(self, agent_info: Content) -> None: if self.agent_ready: self.query_one(AgentInfo).update(agent_info) else: - self.query_one(AgentInfo).update(self.operational_submissions.unavailable_reason) + self.query_one(AgentInfo).update( + self.operational_submissions.unavailable_reason + ) def watch_shell_mode(self) -> None: self.update_prompt() @@ -750,7 +811,9 @@ def compute_display_directory(self) -> str: def watch_working_directory(self, working_directory: str) -> None: if not working_directory: return - out_of_bounds = self.shell_mode and not Path(working_directory).is_relative_to(self.project_path) + out_of_bounds = self.shell_mode and not Path(working_directory).is_relative_to( + self.project_path + ) if out_of_bounds and not self.has_class("-working-directory-out-of-bounds"): self.post_message( messages.Flash( @@ -782,7 +845,12 @@ def update_prompt(self): editor = self.prompt_text_area shell = self.shell_mode editor.placeholder = ( - Content.from_markup("Enter shell command\t[r]▌esc▐[/r] prompt mode").expand_tabs(8) if shell else "") + Content.from_markup( + "Enter shell command\t[r]▌esc▐[/r] prompt mode" + ).expand_tabs(8) + if shell + else "" + ) editor.highlight_language = "shell" if shell else "markdown" self.prompt_label.refresh() @@ -798,8 +866,6 @@ def append(self, text: str) -> None: text, maintain_selection_offset=False ) - - def project_directory_updated(self) -> None: """Called when there may be new files.""" self.path_search.invalidate_paths() @@ -826,7 +892,11 @@ def suggest(self, suggestion: str) -> None: def label_glyph(self) -> Content: if self.shell_mode: return PromptLabel.SHELL - return PromptLabel.MULTILINE if "\n" in self.text or "```" in self.text else PromptLabel.AI + return ( + PromptLabel.MULTILINE + if "\n" in self.text or "```" in self.text + else PromptLabel.AI + ) def compose_completions(self) -> ComposeResult: for kind in CompletionPopup.members_with(CompletionPopup): diff --git a/tests/app/test_agent_binding.py b/tests/app/test_agent_binding.py index 9d2105029..9a48fea43 100644 --- a/tests/app/test_agent_binding.py +++ b/tests/app/test_agent_binding.py @@ -7,7 +7,7 @@ from toad.thread_identity import NoThreadIdentity -def test_no_agent_does_not_match_a_launch(workspace): +async def test_no_agent_does_not_match_a_launch(workspace): view = MainScreen(workspace.project) assert not view.matches_launch("not-bound", "not-bound") assert view.thread_identity == NoThreadIdentity() @@ -35,7 +35,7 @@ async def test_attached_agent_matches_its_live_session_and_relaunch_reuses_the_v await ui.until_shows("PEER_ANSWER binding relaunch journey") -def test_agent_session_matches_its_saved_session_without_crossing_agent_identity( +async def test_agent_session_matches_its_saved_session_without_crossing_agent_identity( workspace, ): definition = peer("echo") diff --git a/tests/textual/test_delivery_draft.py b/tests/textual/test_delivery_draft.py new file mode 100644 index 000000000..fe3ec59ba --- /dev/null +++ b/tests/textual/test_delivery_draft.py @@ -0,0 +1,33 @@ +"""Actual mounted compose controls preserve the draft across delivery failures.""" + +import pytest +from agent_comms.acp_extension import ( + BackendDeliveryFailure, + ClaimBlockedFailure, + UnknownDeliveryFailure, +) + + +@pytest.mark.parametrize( + "failure", + [ + BackendDeliveryFailure("refused"), + ClaimBlockedFailure("blocked"), + UnknownDeliveryFailure("unknown", "private-wire", 1, "private-message"), + ], +) +@pytest.mark.parametrize("draft", ["", "a newer unsent draft"]) +async def test_failure_returns_only_its_text_without_overwriting_a_newer_draft( + workspace, failure, draft +): + workspace.thread("peer", "team") + async with workspace.app() as ui: + await ui.open_channel("#team") + view = ui.conversation + await ui.write_draft(draft) + area = view.prompt.prompt_text_area + document = area.document + failure.present(view, "the failed message") + assert view.prompt.text == (draft or "the failed message") + if draft: + assert area.document is document diff --git a/tests/textual/test_interrupted_send.py b/tests/textual/test_interrupted_send.py deleted file mode 100644 index 85c2eada3..000000000 --- a/tests/textual/test_interrupted_send.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Textual-facing: an interrupted send from a mounted chat view is never replayable (composer widget state and status).""" - -import asyncio -import threading -from unittest.mock import patch - -from agent_comms.comms import wire - -from support.routes import private_root, publish_route, select_root_by_default_route -from support.textual_steps import refresh_chat - - -def times_on_wire(root, body): - return [row.body for row in wire(root).bus.log.full_history()].count(body) - - -async def test_a_direct_message_interrupted_while_its_worker_survives_is_not_replayed(workspace, monkeypatch): - from toad.core import input_events - from toad.widgets.comms_chat import CommsChatView - - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - root, root_id = private_root(attempt / "wire-private", workspace.project, "INITIAL") - publish_route(home, root, root_id) - - async with workspace.app(size=(105, 32)) as ui: - await ui.open_direct("peer", actor="user") - view = ui.textual[0].screen.query_one(CommsChatView) - messaging = view.message_history.source.comms.messaging - original_send = messaging.send_user_message - started, release, settled = threading.Event(), threading.Event(), threading.Event() - calls = 0 - - def delayed_send(*args, **kwargs): - nonlocal calls - calls += 1 - started.set() - try: - if not release.wait(8): - raise TimeoutError("send worker was not released") - return original_send(*args, **kwargs) - finally: - settled.set() - - with patch.object(messaging, "send_user_message", delayed_send): - event = input_events.UserInputSubmitted("CANCELLED-IN-FLIGHT") - task = asyncio.create_task(view.submit_input(event)) - assert await asyncio.to_thread(started.wait, 8) - task.cancel() - await task - assert view._send_block_reason - assert view.prompt.text == event.body - assert view.prompt.prompt_text_area.disabled - await view.submit_input(event) - assert calls == 1 - release.set() - assert await asyncio.to_thread(settled.wait, 8) - await ui.step() - await view.submit_input(event) - assert calls == 1 - assert times_on_wire(root, event.body) == 1 - - -async def test_a_committed_channel_send_keeps_its_identity_and_cannot_be_resent_until_it_is_drawn( - workspace, monkeypatch): - from toad.core import input_events - from toad.widgets.comms_chat import CommsChatView - from toad.wire_history import WireSource - - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - root, root_id = private_root(attempt / "wire-private", workspace.project, "INITIAL") - publish_route(home, root, root_id) - - async with workspace.app(size=(105, 32)) as ui: - await ui.open_channel("#team", actor="user") - channel = ui.textual[0].screen.query_one(CommsChatView) - await refresh_chat(channel) - release = threading.Event() - real_read = WireSource.read - - def held_read(source, request, through): - assert release.wait(8), "the held read was not released" - return real_read(source, request, through) - - messaging = channel.message_history.source.comms.messaging - with (patch.object(WireSource, "read", held_read), - patch.object(messaging, "send_user_message", wraps=messaging.send_user_message) as sender): - sent = input_events.UserInputSubmitted("COMMITTED-NOT-DRAWN") - await channel.submit_input(sent) - rows = [row for row in wire(root).bus.log.full_history() if row.body == sent.body] - assert len(rows) == 1 and sender.call_count == 1 - # The message is on the wire but not drawn: its text stays, unsendable, under its identity. - assert channel._send_block_reason - assert channel.prompt.text == sent.body - assert channel.prompt.prompt_text_area.disabled - assert str(rows[0].seq) in channel.status - assert rows[0].message_id in channel.status - await channel.submit_input(sent) - assert sender.call_count == 1 - release.set() - await ui.until(lambda: not channel._send_block_reason, why="the sent message drawn") - assert ui.shows(sent.body) - assert channel.prompt.text == "" and not channel.prompt.prompt_text_area.disabled From 6de7cf68482fe4c43d9ac573ba5af6317b9f1f61 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:47:54 -0400 Subject: [PATCH 18/43] Consume the declared missing thread presentation through the MRO --- src/toad/acp/agent_session.py | 182 +++++++++++++------ src/toad/acp/comms_updates.py | 17 +- src/toad/acp/transcript_reader.py | 2 +- src/toad/agent.py | 27 ++- src/toad/owner_preparation.py | 15 +- src/toad/session_presentation.py | 46 +++-- src/toad/session_tracker.py | 42 +++-- src/toad/status_thread.py | 13 +- src/toad/thread_identity.py | 6 +- src/toad/widgets/conversation.py | 37 ++-- src/toad/widgets/observed_thread_activity.py | 2 +- tests/app/test_foreign_status_read.py | 22 +++ tests/app/test_session_scope.py | 5 +- tests/textual/test_delivery_draft.py | 2 +- 14 files changed, 265 insertions(+), 153 deletions(-) diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 4e893cbe5..2cd668e03 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -1,26 +1,40 @@ """ACP session handshake, durable metadata and reconnection.""" + from __future__ import annotations -from agent_comms.declared_family import DeclaredFamily -from agent_comms.child_process import join_retirement + import asyncio import os -from dataclasses import dataclass, replace from collections.abc import Callable +from dataclasses import dataclass, replace + +from acp import schema +from acp.exceptions import RequestError +from agent_comms.acp_extension import HistoryOnlyQueueProjection +from agent_comms.acp_failure import ACPFailure +from agent_comms.child_process import join_retirement +from agent_comms.declared_family import DeclaredFamily +from agent_comms.input_attempt import NotSentInput +from agent_comms.owner_protocol import IncompatibleOwner from agent_comms.route_selection import RouteChanged, RouteSelection -from agent_comms.thread_presentation import SessionDisconnected +from agent_comms.session_load import ( + EnsuringSessionLoadPlan, + ExistingSessionLoadPlan, + FailedSessionLoadPlan, + SessionLoadPlan, +) +from agent_comms.thread_presentation import SessionDisconnected, ThreadPresentation + import toad from toad import constants -from acp.exceptions import RequestError -from toad.core import events as core_events -from acp import schema from toad.acp.client_session import ClientSessionRequest -from toad.core.events import AgentFail, AgentReady, UnsupportedResumeAgentFail, LogAgentFail +from toad.core import events as core_events +from toad.core.events import ( + AgentFail, + AgentReady, + LogAgentFail, + UnsupportedResumeAgentFail, +) from toad.db import DB, SessionMeta -from agent_comms.acp_failure import ACPFailure -from agent_comms.acp_extension import HistoryOnlyQueueProjection -from agent_comms.input_attempt import NotSentInput -from agent_comms.owner_protocol import IncompatibleOwner -from agent_comms.session_load import EnsuringSessionLoadPlan, ExistingSessionLoadPlan, FailedSessionLoadPlan, SessionLoadPlan PROTOCOL_VERSION = 1 @@ -59,7 +73,11 @@ class ActiveSessionDisposition(SessionDisposition): accepts_updates = True def present[T](self, ordinary: T, show: Callable[[SessionDisconnected], T]) -> T: - return ordinary if self.session.ready else show(SessionDisconnected("Connecting to agent…")) + return ( + ordinary + if self.session.ready + else show(SessionDisconnected("Connecting to agent…")) + ) def close(self, agent): agent.controller.connection_closed() @@ -71,7 +89,9 @@ class ClosedSessionDisposition(UnavailableSessionDisposition): @dataclass(frozen=True) -class HistoryOnlySessionDisposition(UnavailableSessionDisposition, ActiveSessionDisposition): +class HistoryOnlySessionDisposition( + UnavailableSessionDisposition, ActiveSessionDisposition +): """The thread's owner runs another Core: its history is open, its input refused.""" owner: IncompatibleOwner @@ -130,8 +150,11 @@ def __init__(self, agent, pk, app_events: core_events.CoreEventStream): def publish_thread_row_state(self) -> None: if coordination := self.agent.coordination: - self.app_events.publish(core_events.ThreadRowStateChanged( - frozenset((coordination.thread.name,)))) + self.app_events.publish( + core_events.ThreadRowStateChanged( + frozenset((coordination.thread.name,)) + ) + ) @property def disposition(self) -> SessionDisposition: @@ -146,8 +169,11 @@ def disposition(self, state: SessionDisposition) -> None: @property def capabilities(self): response = self.agent.process.initialization - return (response.agent_capabilities if response is not None - and response.agent_capabilities is not None else schema.AgentCapabilities()) + return ( + response.agent_capabilities + if response is not None and response.agent_capabilities is not None + else schema.AgentCapabilities() + ) @property def accepts_updates(self): @@ -187,8 +213,9 @@ async def retire(self): await join_retirement(self.begin_retirement()) async def _retire(self): - pending = tuple(task for task in (self.task, *self.responses) - if task is not None) + pending = tuple( + task for task in (self.task, *self.responses) if task is not None + ) for task in pending: task.cancel() if pending: @@ -225,14 +252,21 @@ def record_failure( def session_failed(self, failure): self.record_failure( - LogAgentFail(failure.title, failure.feedback, log_path=self.agent.presentation.log_path), + LogAgentFail( + failure.title, + failure.feedback, + log_path=self.agent.presentation.log_path, + ), RefusedSessionDisposition, ) def startup_failed(self, details): self.record_failure( - LogAgentFail("ACP session startup failed", details=details, - log_path=self.agent.presentation.log_path), + LogAgentFail( + "ACP session startup failed", + details=details, + log_path=self.agent.presentation.log_path, + ), FailedSessionDisposition, ) @@ -270,6 +304,7 @@ def coordinated_title(self, title): Otherwise the saved title and the tab follow the thread's title. """ from toad.core.events import SessionInfoUpdate, SessionThreadTitle + self.agent.events.publish(SessionInfoUpdate(self.chosen_name or title)) if self.chosen_name is not None: coordination = self.agent.coordination @@ -293,10 +328,12 @@ async def run(self) -> None: await self.initialize() if self.agent.controller.session.bound: if not self.supports_load: - self.record_failure(UnsupportedResumeAgentFail( - "Resume not supported", - f"{self.agent.definition.name} does not currently support resuming sessions.", - )) + self.record_failure( + UnsupportedResumeAgentFail( + "Resume not supported", + f"{self.agent.definition.name} does not currently support resuming sessions.", + ) + ) return await self.load() await self.touch() @@ -306,23 +343,25 @@ async def run(self) -> None: except RequestError as error: # The handshake has not accepted a prompt, regardless of the # server's reason for refusing this attachment. - failure = replace(ACPFailure.from_error(error), - input_state=NotSentInput) + failure = replace( + ACPFailure.from_error(error), input_state=NotSentInput + ) self.session_failed(failure) return self.settled.set() self.publish_thread_row_state() - self.agent.events.publish(AgentReady(session_id=self.agent.session_id, reconnected=self.reconnecting)) - + self.agent.events.publish( + AgentReady(session_id=self.agent.session_id, reconnected=self.reconnecting) + ) - async def observe_owner(self, presentation) -> None: + async def observe_owner(self, presentation: ThreadPresentation) -> None: """Load again when Core shows a newer owner process than the one this session loaded. A restarted owner answers through the still-open connection, but the queue and cursor views are bound to the process named in the load response and refuse the new one's updates until a new load binds them. """ - if not self.settled.is_set() or self.reconnecting or presentation is None: + if not self.settled.is_set() or self.reconnecting: return if self.agent.coordination is None: return @@ -339,7 +378,9 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: try: try: await asyncio.to_thread( - RouteSelection.capture, self.agent.controller.transcripts.access.launch_root.path) + RouteSelection.capture, + self.agent.controller.transcripts.access.launch_root.path, + ) except RouteChanged as error: self.startup_failed(f"Reconnect blocked before restart: {error}") return @@ -349,7 +390,8 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: await self.agent.start((self.agent.process,)) try: await asyncio.wait_for( - self.settled.wait(), timeout=self.reconnect_timeout_seconds) + self.settled.wait(), timeout=self.reconnect_timeout_seconds + ) except TimeoutError: self.startup_failed( f"Reconnect timed out after {self.reconnect_timeout_seconds} seconds " @@ -360,12 +402,16 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: finally: self.reconnecting = False - async def reconnect_after_login(self) -> None: """Only a session that is ready after its restart counts as a finished login.""" await self.reconnect() if not self.ready: - raise LoginNotReady(self.disposition.present("Session did not become ready after login", lambda status: status.notice)) + raise LoginNotReady( + self.disposition.present( + "Session did not become ready after login", + lambda status: status.notice, + ) + ) async def authenticate(self, method_id: str) -> None: authority = ClientSessionRequest(self.agent, self.agent.session_id) @@ -375,7 +421,6 @@ async def authenticate(self, method_id: str) -> None: await self.initialize(force=True) authority.require() - async def initialize(self, *, force=False): """The connection owns its original initialization response.""" authority = ClientSessionRequest(self.agent, self.agent.session_id) @@ -384,17 +429,22 @@ async def initialize(self, *, force=False): if response is None or force: response = await self.agent.process.rpc.initialize( PROTOCOL_VERSION, - schema.ClientCapabilities(fs=schema.FileSystemCapabilities( - read_text_file=True, write_text_file=True), terminal=True, - auth=schema.AuthCapabilities(terminal=os.name != "nt")), - schema.Implementation(name=toad.NAME, title=toad.TITLE, version=toad.get_version()), + schema.ClientCapabilities( + fs=schema.FileSystemCapabilities( + read_text_file=True, write_text_file=True + ), + terminal=True, + auth=schema.AuthCapabilities(terminal=os.name != "nt"), + ), + schema.Implementation( + name=toad.NAME, title=toad.TITLE, version=toad.get_version() + ), ) authority.require() assert response is not None self.agent.process.initialization = response authority.require() - async def new(self) -> None: """Create a new session.""" authority = ClientSessionRequest(self.agent, self.agent.session_id) @@ -404,7 +454,8 @@ async def new(self) -> None: self.agent._post_private_cursor() self.agent._post_queue_view() response = await self.agent.process.rpc.new_session( - str(self.agent.project_root_path), mcp_servers=[]) + str(self.agent.project_root_path), mcp_servers=[] + ) authority.require() if not self.agent._private_cursor.is_current_request(cursor_token): return @@ -412,8 +463,10 @@ async def new(self) -> None: self.agent.session_id = response.session_id authority = ClientSessionRequest(self.agent, self.agent.session_id) await self.agent.comms_consumer_class( - self.agent, self.agent.session_id, - cursor_token=cursor_token, queue_token=queue_token, + self.agent, + self.agent.session_id, + cursor_token=cursor_token, + queue_token=queue_token, ).consume_metadata(response.field_meta) if self.supports_load: @@ -421,7 +474,11 @@ async def new(self) -> None: session_name = ( self.chosen_name if self.chosen_name is not None - else (self.agent.coordination.title if self.agent.coordination else "New Session") + else ( + self.agent.coordination.title + if self.agent.coordination + else "New Session" + ) ) session_pk = await db.session_new( session_name, @@ -443,7 +500,6 @@ async def new(self) -> None: return self.publish_configuration(response) - async def load(self) -> None: authority = ClientSessionRequest(self.agent, self.agent.session_id) authority.require() @@ -469,7 +525,8 @@ async def load(self) -> None: plan, self.load_plan = self.load_plan, EnsuringSessionLoadPlan() try: response = await self.agent.process.rpc.load_session( - cwd, request_session_id, mcp_servers=[], **(plan.metadata() or {})) + cwd, request_session_id, mcp_servers=[], **(plan.metadata() or {}) + ) except RequestError as error: authority.require() if self.agent.queue_attachment.is_current_request(queue_token): @@ -487,20 +544,25 @@ async def load(self) -> None: if owner is not None: self.agent.queue_attachment.unavailable = HistoryOnlyQueueProjection(owner) await self.agent.comms_consumer_class( - self.agent, self.agent.session_id, - cursor_token=cursor_token, queue_token=queue_token, + self.agent, + self.agent.session_id, + cursor_token=cursor_token, + queue_token=queue_token, ).consume_metadata(response.field_meta) if owner is not None: self.disposition = HistoryOnlySessionDisposition(self, owner) self.publish_configuration(response) - async def rename_coordination(self, display_name: str) -> None: if not self.accepts_session(self.agent.session_id): return - thread = self.agent.coordination.thread.name if self.agent.coordination else None - wire_root = self.agent.coordination.wire_root if self.agent.coordination else None + thread = ( + self.agent.coordination.thread.name if self.agent.coordination else None + ) + wire_root = ( + self.agent.coordination.wire_root if self.agent.coordination else None + ) if thread is None or wire_root is None: return @@ -510,7 +572,9 @@ async def rename_coordination(self, display_name: str) -> None: authority.require() result = await asyncio.to_thread( comms.threads.rename_managed_thread, - thread, display_name, owner_pid=owner_pid, + thread, + display_name, + owner_pid=owner_pid, ) authority.require() self.agent.coordination = replace( @@ -518,8 +582,9 @@ async def rename_coordination(self, display_name: str) -> None: thread=replace(self.agent.coordination.thread, name=result.current), title=display_name, ) - self.agent.events.publish(core_events.CommsUpdated(self.agent.coordination, self.agent.session_id)) - + self.agent.events.publish( + core_events.CommsUpdated(self.agent.coordination, self.agent.session_id) + ) async def set_name(self, name: str) -> None: """The user or agent process named this session. @@ -536,7 +601,6 @@ async def set_name(self, name: str) -> None: db = DB() await db.session_update_title(self.pk, name) - def publish_configuration(self, response): self.load_plan = SessionLoadPlan.at_response(response.field_meta) self.agent.configuration.receive(response.config_options) diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 26e356fed..4478f4061 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -26,7 +26,7 @@ ) from agent_comms.context_reading import UnmeasuredContext from agent_comms.mro_dispatch import MroDispatch, handles -from agent_comms.thread_presentation import ThreadPresentation +from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation from toad.acp.client_session import ClientSessionRequest from toad.acp.context_measurement import ContextMeasurement @@ -219,8 +219,12 @@ def accepts_turn(self): self.turn_token ) + @handles(NoThreadPresentation) + def missing_thread_presentation(self, presentation: NoThreadPresentation) -> object: + return self.DISCARD + @handles(ThreadPresentation) - def thread_presentation(self, presentation): + def thread_presentation(self, presentation: ThreadPresentation): identity = presentation.read_identity if identity is None: return # An unavailable source supplies no turn settlement. @@ -254,7 +258,7 @@ class ChangedAttachmentRead(ThreadReadOutcome): @dataclass(frozen=True) class SettledThreadRead(ThreadReadOutcome): - presentation: ThreadPresentation | None + presentation: ThreadPresentation def present(self, consumer) -> None: consumer(self.presentation) @@ -263,7 +267,7 @@ def present(self, consumer) -> None: class ThreadRead: """A presentation read, with settlement owned by its captured source.""" - def settle(self, presentation: ThreadPresentation | None) -> ThreadReadOutcome: + def settle(self, presentation: ThreadPresentation) -> ThreadReadOutcome: return SettledThreadRead(presentation) async def reconcile(self, transcripts) -> None: @@ -288,7 +292,7 @@ class PendingThreadRead(ThreadRead): authority: ClientSessionRequest snapshot: OwnerSnapshotConsumer - def settle(self, presentation: ThreadPresentation | None) -> ThreadReadOutcome: + def settle(self, presentation: ThreadPresentation) -> ThreadReadOutcome: current = self.agent.coordination if current is None or (current.wire_root, current.thread) != ( self.coordination.wire_root, @@ -297,8 +301,7 @@ def settle(self, presentation: ThreadPresentation | None) -> ThreadReadOutcome: return ChangedAttachmentRead() if self.authority.retired: return EndedSessionRead() - if presentation is not None: - self.snapshot.dispatch_sync(presentation) + self.snapshot.dispatch_sync(presentation) return SettledThreadRead(presentation) async def reconcile(self, transcripts) -> None: diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index 3808197a5..15d6519bd 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -50,7 +50,7 @@ async def service(self, root): async def thread_presentation( self, root: str, thread: str - ) -> StoredRead[ThreadPresentation | None]: + ) -> StoredRead[ThreadPresentation]: """The stored-read owner classifies failures inside the read worker.""" async with self.bind(root) as reader: return await asyncio.to_thread( diff --git a/src/toad/agent.py b/src/toad/agent.py index cfbd50723..2775f7505 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -1,13 +1,14 @@ -from toad.agent_presentation import LocalAgentPresentation -from toad.agent_binding import AgentBinding -from textual.content import Content -from toad.acp.status import StopReason -from toad.conversation_turn import NoTurn from abc import abstractmethod from pathlib import Path + from agent_comms.thread_presentation import ThreadPresentation -from toad.core.events import CoreEventStream +from textual.content import Content +from toad.acp.status import StopReason +from toad.agent_binding import AgentBinding +from toad.agent_presentation import LocalAgentPresentation +from toad.conversation_turn import NoTurn +from toad.core.events import CoreEventStream class AgentBase(AgentBinding): @@ -18,6 +19,7 @@ def __init__(self, project_root: Path) -> None: self.events = CoreEventStream(self) self.presentation = LocalAgentPresentation(self) from toad.acp.agent_configuration import AgentConfiguration + self.configuration = AgentConfiguration(self) ready = False @@ -25,6 +27,7 @@ def __init__(self, project_root: Path) -> None: def attach_view(self, view) -> None: from toad.agent_surface import AttachedSurfaceBinding from toad.core.events import SessionAgentAttached + self.attach_surface(AttachedSurfaceBinding(view, self.events)) view.publish_core(SessionAgentAttached()) @@ -59,8 +62,11 @@ def begin_submission(self, owner, submission): def accepts_failed_input(self, message): if message.publisher is not self or message.event.session_id != self.session_id: return False - return (self.queue_attachment.accepts_request(message.event.queue_scope) - if message.event.recover_draft else True) + return ( + self.queue_attachment.accepts_request(message.event.queue_scope) + if message.event.recover_draft + else True + ) def send_now(self, owner): return owner.send_bound_now(self) @@ -74,6 +80,7 @@ def automatic_titles(self): async def project_changed(self, path) -> None: from toad.db import DB + self.project_root_path = path if (session_pk := self.session.pk) is not None: await DB().session_update_project(session_pk, path) @@ -127,7 +134,9 @@ def observed_thread(self) -> tuple[str, str] | None: """The (wire root, thread name) whose status this agent shows; none without coordination.""" return None - async def observe_thread_presentation(self, presentation: ThreadPresentation | None) -> None: + async def observe_thread_presentation( + self, presentation: ThreadPresentation + ) -> None: """Consume a published canonical change; unmanaged agents need no binding.""" @abstractmethod diff --git a/src/toad/owner_preparation.py b/src/toad/owner_preparation.py index 8b7d28cc6..68ef6aa44 100644 --- a/src/toad/owner_preparation.py +++ b/src/toad/owner_preparation.py @@ -3,9 +3,14 @@ from dataclasses import dataclass from agent_comms.comms import Comms -from agent_comms.thread_presentation import ThreadPresentation, ThreadOwnerBinding, LiveThreadOwnerBinding -from agent_comms.session_load import ExistingSessionLoadPlan from agent_comms.mro_dispatch import handles +from agent_comms.session_load import ExistingSessionLoadPlan +from agent_comms.thread_presentation import ( + LiveThreadOwnerBinding, + ThreadOwnerBinding, + ThreadPresentation, +) + from toad.core.projection import MroProjection @@ -21,7 +26,9 @@ def live(self, binding): return ExistingSessionLoadPlan(binding) @classmethod - def from_binding(cls, binding: ThreadOwnerBinding) -> ExistingSessionLoadPlan | None: + def from_binding( + cls, binding: ThreadOwnerBinding + ) -> ExistingSessionLoadPlan | None: return cls().dispatch_sync(binding) @@ -37,6 +44,6 @@ class OwnerRequestContext: _comms: Comms -def read_thread_presentation(comms: Comms, name: str) -> ThreadPresentation | None: +def read_thread_presentation(comms: Comms, name: str) -> ThreadPresentation: """Read the existing core presentation on a worker, without UI dependencies.""" return comms.views.thread_presentations((name,)).read(name) diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 355fba6bc..63ec6a87a 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -46,7 +46,7 @@ class SessionViewState: initial_prompt: str | None @classmethod - def capture(cls, conversation: Conversation) -> "SessionViewState": + def capture(cls, conversation: Conversation) -> SessionViewState: editor = conversation.prompt.prompt_text_area return cls( editor.capture_editor_state(), @@ -76,16 +76,16 @@ def operational_binding(self): return NoAgentBound() @abstractmethod - def compose_content(self, screen: "MainScreen") -> Widget: ... + def compose_content(self, screen: MainScreen) -> Widget: ... @abstractmethod - async def prepare(self, screen: "MainScreen") -> None: ... + async def prepare(self, screen: MainScreen) -> None: ... @abstractmethod - async def retire(self, screen: "MainScreen") -> None: ... + async def retire(self, screen: MainScreen) -> None: ... @abstractmethod - async def close(self, screen: "MainScreen") -> None: ... + async def close(self, screen: MainScreen) -> None: ... class EditorSessionSurfaceLifetime(SessionSurfaceLifetime): @@ -96,7 +96,7 @@ def __init__(self) -> None: @abstractmethod async def release_binding( - self, conversation: Conversation, screen: "MainScreen" + self, conversation: Conversation, screen: MainScreen ) -> None: ... @abstractmethod @@ -150,10 +150,7 @@ def input_ready(self, conversation: Conversation) -> bool: return False if conversation.transcript.readable: presentation = conversation.thread_presentation - return ( - presentation is not None - and ExistingOwnerPlan.from_binding(presentation.binding) is not None - ) + return ExistingOwnerPlan.from_binding(presentation.binding) is not None return True def unavailable_reason(self, conversation: Conversation) -> str: @@ -161,7 +158,6 @@ def unavailable_reason(self, conversation: Conversation) -> str: agent = conversation.agent_binding if ( conversation.transcript.readable - and presentation is not None and ExistingOwnerPlan.from_binding(presentation.binding) is None ): line = self.activity_row(conversation).presentation.summary @@ -205,7 +201,7 @@ def create_agent(self, conversation: Conversation) -> None: core_events.SessionSubtitleChanged(conversation.agent_title) ) - def attach_started(self, screen: "MainScreen", plan) -> None: + def attach_started(self, screen: MainScreen, plan) -> None: """An explicit Start action may attach the same retained view to its new owner.""" self.load_plan = plan binding = screen.attach_agent_definition() @@ -220,7 +216,7 @@ async def present(self, conversation: Conversation) -> None: await shell.attach(AttachedSurfaceBinding(conversation, shell.events)) conversation.bind_agent(self.agent_binding) - async def detach(self, conversation: Conversation, screen: "MainScreen") -> None: + async def detach(self, conversation: Conversation, screen: MainScreen) -> None: self.agent_binding = conversation.agent_binding self.shell = conversation._shell conversation._shell = None @@ -313,17 +309,17 @@ def retained_paint_bytes(self) -> int: for page in history.pages ) - def compose_content(self, screen: "MainScreen") -> Widget: + def compose_content(self, screen: MainScreen) -> Widget: return SessionSurfaceSlot() - async def prepare(self, screen: "MainScreen") -> None: + async def prepare(self, screen: MainScreen) -> None: await screen.app.workspace_chrome.native.activate(screen) - async def retire(self, screen: "MainScreen") -> None: + async def retire(self, screen: MainScreen) -> None: await screen.app.workspace_chrome.native.retire(screen) async def release_binding( - self, conversation: Conversation, screen: "MainScreen" + self, conversation: Conversation, screen: MainScreen ) -> None: await self.sources.detach(conversation, screen) @@ -331,7 +327,7 @@ async def attach_binding(self, conversation: Conversation) -> None: await self.sources.present(conversation) async def apply_coordination( - self, screen: "MainScreen", event: CoordinationChangedUpdate + self, screen: MainScreen, event: CoordinationChangedUpdate ) -> None: """Apply a coordination update's view work to this session's conversation. @@ -350,7 +346,7 @@ async def apply_coordination( conversation.input_histories.bind_scope(f"thread:{event.thread.name}") @asynccontextmanager - async def acquire(self, screen: "MainScreen"): + async def acquire(self, screen: MainScreen): """Mount owned resources before the workspace opens the view, then restore them. The workspace accepts the yielded native tree before operational @@ -384,7 +380,7 @@ async def acquire(self, screen: "MainScreen"): conversation.start_native_session() conversation.prompt.focus() - async def close(self, screen: "MainScreen") -> None: + async def close(self, screen: MainScreen) -> None: self.state = None async with AsyncResourceRelease() as release: release.push_async_callback(self.sources.close) @@ -415,7 +411,7 @@ async def remove_native_tree(self) -> None: class NativeSessionSurface: """Accept session-owned trees without moving their ancestry or copying their state.""" - def __init__(self, app: "ToadApp") -> None: + def __init__(self, app: ToadApp) -> None: self._app = ref(app) self.view: MainScreen | None = None self._lock = asyncio.Lock() @@ -432,7 +428,7 @@ def _presentations(self): for presentation in view.retained_native_presentations() ) - async def retire(self, screen: "MainScreen") -> None: + async def retire(self, screen: MainScreen) -> None: async with self._lock: if self.view is not screen or self.widget is None: return @@ -443,7 +439,7 @@ async def retire(self, screen: "MainScreen") -> None: await screen.presentation.release_binding(conversation, screen) self.view = None - async def activate(self, screen: "MainScreen") -> None: + async def activate(self, screen: MainScreen) -> None: async with self._lock: if self.view is screen: return @@ -454,7 +450,7 @@ async def activate(self, screen: "MainScreen") -> None: self.view = screen await self._trim_retained(conversation) - async def reconcile(self, screen: "MainScreen") -> None: + async def reconcile(self, screen: MainScreen) -> None: """Completed rich-panel hydration changes the same native working set.""" async with self._lock: if self.view is screen and self.widget is not None: @@ -501,7 +497,7 @@ async def _trim_retained(self, selected: Conversation) -> None: if owner not in kept: await owner.evict() - async def dispose(self, screen: "MainScreen") -> None: + async def dispose(self, screen: MainScreen) -> None: """Finalize a live tree before pruning; a closed tab retains no editor.""" async with self._lock: owner = screen.presentation diff --git a/src/toad/session_tracker.py b/src/toad/session_tracker.py index 17fd604a1..42afcb8b9 100644 --- a/src/toad/session_tracker.py +++ b/src/toad/session_tracker.py @@ -2,19 +2,20 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field -from time import time from operator import attrgetter +from time import time from typing import TYPE_CHECKING, Iterable, Sequence -from toad.core.events import CoreEventStream, SessionChangedEvent, SessionClosedEvent -from agent_comms.ui_model.unread import ExactUnread, UnreadPresentation from agent_comms.ui_model.sidebar import ThreadRowModel +from agent_comms.ui_model.unread import ExactUnread, UnreadPresentation from textual.content import Content + +from toad.core.events import CoreEventStream, SessionChangedEvent, SessionClosedEvent + if TYPE_CHECKING: from toad.conversation_kind import ConversationKind - @dataclass(frozen=True) class OpenTab(ABC): """A tab reads its label and activity from the source it represents.""" @@ -123,20 +124,20 @@ def is_placeholder(self) -> bool: return self.authored is None and self.thread_title == "New Session" @classmethod - def authored_name(cls, name: str) -> "SessionName": + def authored_name(cls, name: str) -> SessionName: return cls(name) @classmethod - def following(cls, thread_title: str) -> "SessionName": + def following(cls, thread_title: str) -> SessionName: return cls(None, thread_title) - def present_thread_title(self, thread_title: str) -> "SessionName | None": + def present_thread_title(self, thread_title: str) -> SessionName | None: """The name after the thread presents a title, or None if it cannot win.""" if self.authored is not None: return None return type(self)(None, thread_title) - def adopt_identity(self, previous: str, current: str) -> "SessionName | None": + def adopt_identity(self, previous: str, current: str) -> SessionName | None: """The thread behind this session is now named `current`. A tab that shows no name of its own yet, or still shows the old @@ -158,7 +159,9 @@ class SessionDetails: """Index of session, used in sorting.""" mode_name: str """The screen mode name.""" - name: SessionName = field(default_factory=lambda: SessionName.following("New Session")) + name: SessionName = field( + default_factory=lambda: SessionName.following("New Session") + ) """The name of the conversation: the user's choice or the thread's title.""" subtitle: str = "" """The subtitle of the conversation.""" @@ -175,10 +178,10 @@ def initial_title(self) -> str | None: """The workspace placeholder is not an authored session name.""" return None if self.name.is_placeholder else self.name.display - def bind_initial_identity(self, session_id: str | None) -> None: - """An existing native identity names an otherwise unnamed session.""" - if session_id is not None and self.name.is_placeholder: - self.update_thread_title(session_id) + def bind_initial_identity(self, thread_name: str) -> None: + """The bound thread names an otherwise unnamed session.""" + if self.name.is_placeholder: + self.update_thread_title(thread_name) def name_session(self, name: str) -> None: """The user named this session; no presented title replaces it.""" @@ -193,7 +196,6 @@ def update_thread_title(self, thread_title: str) -> bool: return True - class SessionTracker: """Tracks concurrent agent settings""" @@ -210,7 +212,8 @@ def new_session(self, *, title: str = "New Session") -> SessionDetails: self._session_index += 1 mode_name = f"session-{self._session_index}" session_meta = SessionDetails( - index=self._session_index, mode_name=mode_name, + index=self._session_index, + mode_name=mode_name, name=SessionName.following(title), ) self.sessions[mode_name] = session_meta @@ -249,14 +252,18 @@ def update_session( ) -> SessionDetails: session_details = self.sessions[mode_name] before = ( - session_details.name.display, session_details.subtitle, session_details.path, + session_details.name.display, + session_details.subtitle, + session_details.path, ) if subtitle is not None: session_details.subtitle = subtitle if path is not None: session_details.path = path after = ( - session_details.name.display, session_details.subtitle, session_details.path, + session_details.name.display, + session_details.subtitle, + session_details.path, ) if after != before: self.events.publish(SessionChangedEvent(mode_name)) @@ -268,4 +275,3 @@ def ordered_sessions(self) -> Sequence[SessionDetails]: def __iter__(self) -> Iterable[SessionDetails]: return iter(self.ordered_sessions) - diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py index 6f50e6284..40070efbe 100644 --- a/src/toad/status_thread.py +++ b/src/toad/status_thread.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from agent_comms.thread_presentation import ThreadPresentation +from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation from agent_comms.ui_model.observation import StoredRead, UnreadableHistory from agent_comms.ui_model.status import ThreadStatusModel @@ -97,24 +97,27 @@ async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: self.present_read(view, read) def present_read( - self, view: ThreadStatusOwner, read: StoredRead[ThreadPresentation | None] + self, view: ThreadStatusOwner, read: StoredRead[ThreadPresentation] ) -> None: if view.status_thread() != self: return read.answer( partial(self.present_value, view), - lambda: None, + self.present_deferred, partial(self.present_unreadable, view), ) def present_value( - self, view: ThreadStatusOwner, presentation: ThreadPresentation | None + self, view: ThreadStatusOwner, presentation: ThreadPresentation ) -> None: view.thread_activity.show(ThreadStatusModel.known(self.name, presentation)) view.foreign_thread_read(presentation) + def present_deferred(self) -> None: + """An unfinished read leaves the previous row and presentation untouched.""" + def present_unreadable( self, view: ThreadStatusOwner, error: UnreadableHistory ) -> None: view.thread_activity.show(ThreadStatusModel.unreadable(self.name, error)) - view.foreign_thread_read(None) + view.foreign_thread_read(NoThreadPresentation()) diff --git a/src/toad/thread_identity.py b/src/toad/thread_identity.py index 08bdb1831..b4b1539dd 100644 --- a/src/toad/thread_identity.py +++ b/src/toad/thread_identity.py @@ -1,5 +1,7 @@ """A session's absent, local or wire-bound thread, with its owned behavior.""" +from __future__ import annotations + from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass @@ -75,7 +77,7 @@ def thread_incarnation(self) -> ThreadIncarnation: def bind_history(self, histories) -> None: """An unbound session keeps its native input-history scope.""" - def bind_initial_identity(self, details: "SessionDetails") -> None: + def bind_initial_identity(self, details: SessionDetails) -> None: """A session without a thread has no initial thread title.""" def resolve_thread(self, registry, project: Path) -> str: @@ -104,7 +106,7 @@ def command_target[T](self, target: Callable[[str], T]) -> T: @dataclass(frozen=True) class NamedThreadIdentity(SessionIdentity, ABC): - def bind_initial_identity(self, details: "SessionDetails") -> None: + def bind_initial_identity(self, details: SessionDetails) -> None: details.bind_initial_identity(self.thread_name()) def display_thread(self) -> str: diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index d485d65c0..e351f94d2 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -13,7 +13,8 @@ from acp import schema as acp_protocol from agent_comms.goal_presentation import IdleGoalExecution, ThreadGoalExecution from agent_comms.goals import NoGoal -from agent_comms.mro_dispatch import handles +from agent_comms.mro_dispatch import MroDispatch, handles +from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation from agent_comms.ui_model.delivery import InputDelivery from agent_comms.ui_model.goal import GoalDisplay from agent_comms.ui_model.message_category import MessageCategory, all_categories @@ -377,17 +378,17 @@ def prepare_viewport(self) -> None: class ConversationSessionBinding( - WorkspaceHosted, ThreadStatusOwner, containers.Vertical + MroDispatch, WorkspaceHosted, ThreadStatusOwner, containers.Vertical ): """Source-bound state and reusable rich-surface lifecycle shared by conversations.""" - thread_presentation = None + thread_presentation: ThreadPresentation = NoThreadPresentation() """The agent thread's latest settled presentation.""" _thread_read = NoPendingThreadRead() """The source state the next observed presentation settles against.""" - _unpublished_presentation = None + _unpublished_presentation: ThreadPresentation = NoThreadPresentation() """An observed presentation whose transcript refresh waits for this view to be shown.""" @property @@ -416,19 +417,28 @@ def thread_observed(self, event: core_events.ThreadRowsChanged) -> None: self.arm_thread_read() read.settle(presentation).present(self.thread_presented) - def foreign_thread_read(self, presentation) -> None: + def foreign_thread_read(self, presentation: ThreadPresentation) -> None: self.thread_presented(presentation) - def thread_presented(self, presentation) -> None: - """A settled presentation refreshes the transcript source of the shown view.""" + def thread_presented(self, presentation: ThreadPresentation) -> None: + """A settled member owns whether this view has source work to publish.""" self.thread_presentation = presentation self._update_model_info() - if presentation is None or not self.transcript.readable: + self.dispatch_sync(presentation) + + @handles(NoThreadPresentation) + def _no_thread_source(self, presentation: NoThreadPresentation) -> object: + self._unpublished_presentation = presentation + return self.DISCARD + + @handles(ThreadPresentation) + def _publish_thread_source(self, presentation: ThreadPresentation) -> None: + if not self.transcript.readable: return if self.query_ancestor(SessionScope).is_current: from toad.transcript_publication import ObservedSourcePublication - self._unpublished_presentation = None + self._unpublished_presentation = NoThreadPresentation() self.transcript.source_requests.request( ObservedSourcePublication, presentation ) @@ -441,14 +451,7 @@ async def _thread_view_selected(self, event: CoreEventMessage) -> None: return self.refresh_thread_status() self.goal_observation.invalidate() - presentation = self._unpublished_presentation - if presentation is not None: - from toad.transcript_publication import ObservedSourcePublication - - self._unpublished_presentation = None - self.transcript.source_requests.request( - ObservedSourcePublication, presentation - ) + self.dispatch_sync(self._unpublished_presentation) @handles(core_events.ObservedServiceChanged) async def _thread_route_observed(self, event: CoreEventMessage) -> None: diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index 974152987..66ecf5c6d 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -102,7 +102,7 @@ def status_thread(self) -> StatusThread: def thread_observed(self, event: ThreadRowsChanged) -> None: """Consume the observed thread's presentation once per store change.""" - def foreign_thread_read(self, presentation: ThreadPresentation | None) -> None: + def foreign_thread_read(self, presentation: ThreadPresentation) -> None: """Consume a foreign thread's presentation after its own read.""" def refresh_thread_status(self) -> None: diff --git a/tests/app/test_foreign_status_read.py b/tests/app/test_foreign_status_read.py index 19d757d4f..e93cc31be 100644 --- a/tests/app/test_foreign_status_read.py +++ b/tests/app/test_foreign_status_read.py @@ -4,6 +4,8 @@ from agent_comms.bus_route_counts import RouteIndexRebuilding from agent_comms.comms import Comms from agent_comms.coordination_errors import CoordinationReadUnavailable +from agent_comms.errors import UnregisteredThreadError +from agent_comms.thread_presentation import NoThreadPresentation from agent_comms.ui_model.observation import ReadDeferred, ReadPreparing from agent_comms.ui_model.status import ThreadStatusModel from textual.app import ComposeResult @@ -53,6 +55,26 @@ async def test_deferred_foreign_read_keeps_the_previous_status_row(workspace, re assert surface.thread_activity.row is previous +async def test_foreign_missing_thread_keeps_its_member_through_read_and_status( + workspace, +): + foreign = Comms(workspace.roots.attempt / "missing-foreign-wire") + async with workspace.app() as ui: + app, _ = ui.textual + source = ForeignStatusThread(foreign.root, "gone") + surface = ForeignStatusSurface(source) + await app.selected_session.mount(surface) + read = await app.coordination_access.transcripts.thread_presentation( + str(foreign.root), "gone" + ) + assert read.raised() == NoThreadPresentation() + source.present_read(surface, read) + shown = surface.thread_activity.row.presentation + assert type(shown) is NoThreadPresentation + with pytest.raises(UnregisteredThreadError): + shown.require("gone") + + async def test_foreign_read_presents_the_real_stored_thread(workspace): foreign = Comms(workspace.roots.attempt / "foreign-wire") foreign.registry.declare( diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index 3cab3f0da..4d99993dd 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -1,7 +1,5 @@ """Session consumers discover the existing mounted owner without a screen cycle.""" -import sys - import pytest from agent_comms.errors import RelationViolationError from agent_comms.launch_root import LaunchRoot @@ -10,8 +8,7 @@ from toad.session_scope import NoSessionOrigin, NoTranscript, SessionScope -async def test_scope_import_and_real_conversation_ancestor(workspace): - assert "toad.screens.session_view" not in sys.modules +async def test_real_conversation_discovers_screen_free_scope(workspace): unavailable = NoSessionOrigin("closed-test-view") assert unavailable.transcript_scope() == NoTranscript() with pytest.raises(RelationViolationError, match="closed-test-view"): diff --git a/tests/textual/test_delivery_draft.py b/tests/textual/test_delivery_draft.py index fe3ec59ba..da4dbf86d 100644 --- a/tests/textual/test_delivery_draft.py +++ b/tests/textual/test_delivery_draft.py @@ -22,7 +22,7 @@ async def test_failure_returns_only_its_text_without_overwriting_a_newer_draft( ): workspace.thread("peer", "team") async with workspace.app() as ui: - await ui.open_channel("#team") + await ui.open_channel("#team", actor=workspace.viewer) view = ui.conversation await ui.write_draft(draft) area = view.prompt.prompt_text_area From baeb40c3a470339f4cac60d0eb8181f39ccb5578 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:59:44 -0400 Subject: [PATCH 19/43] Track paired package mains and include the presentation owner --- pyproject.toml | 22 +++++++++++----------- uv.lock | 24 ++++++++++++------------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1e5c2cdd9..56fb7fa2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "A unified experience for AI in your terminal." readme = "README.md" requires-python = ">=3.14" dependencies = [ - "textual[syntax]==8.2.8", + "textual[syntax]", "click==8.4.0", "httpx==0.28.1", "platformdirs==4.9.6", @@ -14,7 +14,7 @@ dependencies = [ "xdg-base-dirs>=6.0.2", "textual-serve==1.1.3", "textual-speedups==0.2.1", - "textual-image[textual]==0.14.1", + "textual-image[textual]", "packaging==26.2", "bashlex==0.18", "pathspec==1.1.1", @@ -24,27 +24,27 @@ dependencies = [ "setproctitle==1.3.7", "psutil==7.2.2", "aiosqlite==0.22.1", - "textual-diff-view==0.1.5", + "textual-diff-view", "typing-extensions==4.15.0", "format-currency>=0.0.10", - "agent-comms[acp]", + "agent-comms", "agent-client-protocol==0.12.1", "ui-theme", - "formbridge==0.2.0", - "objectstate==5.0.0", + "formbridge", + "objectstate", ] [tool.uv.workspace] members = ["toad"] [tool.uv.sources] -ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "60a324455552a6feeb3868c41551948a58bdcc67" } +ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = "main" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } -formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } -objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } -textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } -textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } +formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } +objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", branch = "main" } +textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", branch = "main" } +textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", branch = "main" } [project.scripts] toad = "toad.cli:main" diff --git a/uv.lock b/uv.lock index 66697be03..cde24cba8 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main#aaeb75fea7f80a47b7e088150974a655a480a5ad" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main#776f758e4f504ef280447b65c53ddd01efa4e404" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, @@ -302,15 +302,15 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", extras = ["acp"], git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main" }, + { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, { name = "format-currency", specifier = ">=0.0.10" }, - { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?rev=ebe2fa9173428e2bd7154a0de23698757677c3c2" }, + { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?branch=main" }, { name = "httpx", specifier = "==0.28.1" }, { name = "notify-py", specifier = "==0.3.43" }, - { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400" }, + { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?branch=main" }, { name = "packaging", specifier = "==26.2" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "platformdirs", specifier = "==4.9.6" }, @@ -319,13 +319,13 @@ requires-dist = [ { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?branch=main" }, - { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, - { name = "textual-image", extras = ["textual"], git = "https://github.com/OpenHCSDev/textual-image.git?rev=1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" }, + { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?branch=main" }, + { name = "textual-image", extras = ["textual"], git = "https://github.com/OpenHCSDev/textual-image.git?branch=main" }, { name = "textual-serve", specifier = "==1.1.3" }, { name = "textual-speedups", specifier = "==0.2.1" }, { name = "typeguard", specifier = "==4.5.2" }, { name = "typing-extensions", specifier = "==4.15.0" }, - { name = "ui-theme", git = "https://github.com/OpenHCSDev/ui-theme.git?rev=60a324455552a6feeb3868c41551948a58bdcc67" }, + { name = "ui-theme", git = "https://github.com/OpenHCSDev/ui-theme.git?branch=main" }, { name = "watchdog", specifier = "==6.0.0" }, { name = "xdg-base-dirs", specifier = ">=6.0.2" }, ] @@ -471,7 +471,7 @@ wheels = [ [[package]] name = "formbridge" version = "0.2.0" -source = { git = "https://github.com/OpenHCSDev/formbridge.git?rev=ebe2fa9173428e2bd7154a0de23698757677c3c2#ebe2fa9173428e2bd7154a0de23698757677c3c2" } +source = { git = "https://github.com/OpenHCSDev/formbridge.git?branch=main#ebe2fa9173428e2bd7154a0de23698757677c3c2" } dependencies = [ { name = "docutils" }, { name = "metaclass-registry" }, @@ -1081,7 +1081,7 @@ wheels = [ [[package]] name = "objectstate" version = "5.0.0" -source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400#b5dd9f78b9293df145d7629f21deb89b042b9400" } +source = { git = "https://github.com/OpenHCSDev/objectstate.git?branch=main#3f795a729306b2b11892b0afb07572aae2e41f74" } dependencies = [ { name = "dill" }, { name = "pycodify" }, @@ -1653,7 +1653,7 @@ wheels = [ [[package]] name = "textual-diff-view" version = "0.1.5" -source = { git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9#8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } +source = { git = "https://github.com/OpenHCSDev/textual-diff-view.git?branch=main#388dd69cf550f02273605a561a5a0bee5dd2ea35" } dependencies = [ { name = "textual" }, ] @@ -1661,7 +1661,7 @@ dependencies = [ [[package]] name = "textual-image" version = "0.14.1" -source = { git = "https://github.com/OpenHCSDev/textual-image.git?rev=1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80#1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } +source = { git = "https://github.com/OpenHCSDev/textual-image.git?branch=main#1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } dependencies = [ { name = "pillow" }, { name = "rich" }, @@ -2004,7 +2004,7 @@ wheels = [ [[package]] name = "ui-theme" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/ui-theme.git?rev=60a324455552a6feeb3868c41551948a58bdcc67#60a324455552a6feeb3868c41551948a58bdcc67" } +source = { git = "https://github.com/OpenHCSDev/ui-theme.git?branch=main#60a324455552a6feeb3868c41551948a58bdcc67" } dependencies = [ { name = "metaclass-registry" }, { name = "terminaltexteffects" }, From dee358a96e789b841541a0074a6656e9ab40a5a7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:17:35 -0400 Subject: [PATCH 20/43] Own compose controls and remove the false other-route read --- src/toad/delivery_failure_view.py | 95 +++++++++++++++----- src/toad/status_thread.py | 48 +--------- src/toad/widgets/conversation.py | 38 ++------ src/toad/widgets/observed_thread_activity.py | 4 - src/toad/widgets/prompt.py | 14 +-- tests/app/test_foreign_status_read.py | 93 ------------------- tests/app/test_other_route_status.py | 40 +++++++++ 7 files changed, 128 insertions(+), 204 deletions(-) delete mode 100644 tests/app/test_foreign_status_read.py create mode 100644 tests/app/test_other_route_status.py diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index e25f2d94d..8b117812f 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -1,41 +1,68 @@ """The concrete compose controls used by shared delivery-failure declarations.""" +from __future__ import annotations + import asyncio +from abc import abstractmethod +from collections.abc import Awaitable, Callable from dataclasses import dataclass +from pathlib import Path +from typing import Literal +from agent_comms.acp_extension import UnknownDeliveryFailure from agent_comms.errors import HumanClaimBlockedError, HumanInitialUnknownError +from agent_comms.messages import Message +from rich.text import Text as RichText +from textual import getters +from textual.content import Content +from textual.reactive import var +from textual.widget import Widget + +from toad.app_node import ToadNode +from toad.widgets.flash import Flash +from toad.widgets.prompt import Prompt class SendState: """Where this view's own human-message send stands; compose follows it.""" - reason = "" + @property + def reason(self) -> str: + return "" - def drawn_in(self, view) -> bool: + def drawn_in(self, view: DeliveryFailureView) -> bool: """Whether this send is now drawn in `view`, so compose can open again.""" return False + async def present(self, accepted: Callable[[Message], Awaitable[object]]) -> None: + """An unconfirmed send has no accepted message to present.""" + class NoSend(SendState): pass class SendPending(SendState): - reason = "Send pending; do not retry" + @property + def reason(self) -> str: + return "Send pending; do not retry" @dataclass(frozen=True) class SentUndrawn(SendState): - root: str - sent: object + root: Path + sent: Message @property def reason(self) -> str: return f"Sent {self.root}/{self.sent.seq}/{self.sent.message_id}; waiting to be drawn, do not retry" - def drawn_in(self, view) -> bool: + def drawn_in(self, view: DeliveryFailureView) -> bool: return view.draws_user_message(self.sent) + async def present(self, accepted: Callable[[Message], Awaitable[object]]) -> None: + await accepted(self.sent) + @dataclass(frozen=True) class SendBlocked(SendState): @@ -48,7 +75,7 @@ def reason(self) -> str: @dataclass(frozen=True) class SendUnknown(SendState): - failure: object + failure: UnknownDeliveryFailure @property def reason(self) -> str: @@ -59,8 +86,28 @@ def reason(self) -> str: ) -class DeliveryFailureView: - """Own human-message delivery and its compose/failure controls.""" +class DeliveryFailureView(ToadNode, Widget): + """The composed input, status and delivery behavior shared by conversations.""" + + prompt = getters.query_one(Prompt) + status: var[str | Content | RichText] = var("") + + @abstractmethod + def draws_user_message(self, message: Message) -> bool: ... + + async def composer_read_only(self) -> bool: + """Native and CLI sessions compose to their exact thread, not a saved channel.""" + return False + + def flash( + self, + content: str | Content, + *, + duration: float | None = None, + style: Literal["default", "warning", "error", "success"] = "default", + ) -> None: + """Show feedback in this view's composed flash control.""" + self.query_one(Flash).flash(content, duration=duration, style=style) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -82,17 +129,18 @@ async def refresh() -> None: self.run_worker(refresh(), group="composer-access", exclusive=True) - async def refresh_composer(self, read_only: bool, status: str = "") -> None: - """Draw compose from the caller's Core answer and the current send state.""" - if not self.is_attached: - return + async def refresh_composer(self, read_only: bool, status: str = "") -> str: + """Draw compose and return the reason from the same delivery decision.""" reason = self.send_block_reason or ("Read-only saved view" if read_only else "") + if not self.is_attached: + return reason area = self.prompt.prompt_text_area area.disabled = bool(reason) area.tooltip = reason or None self.status = reason or status if not area.disabled and self.screen.focused is None: self.screen.set_focus(area) + return reason async def submit_comms_message(self, root, conversation, body, current, present): """CLI transcripts and wire pages use the same Core-send checks.""" @@ -102,10 +150,10 @@ async def submit_comms_message(self, root, conversation, body, current, present) read_only = await self.app.preparation.run_thread( conversation.read_only, comms ) - await self.refresh_composer(read_only) + reason = await self.refresh_composer(read_only) if read_only: self.restore_user_message(body) - self.flash(self.status, style="error") + self.flash(reason, style="error") return await self.deliver_user_message( root, conversation.send_target().name, body, current, present @@ -136,10 +184,8 @@ async def require_route(): except (HumanInitialUnknownError, HumanClaimBlockedError) as error: delivery_failure(error).present(self, body) return - sent = await self.commit_user_message(comms, target, body) - if sent is None: - return - await present(sent) + outcome = await self.commit_user_message(comms, target, body) + await outcome.present(present) async def commit_user_message(self, comms, target, body): from agent_comms.delivery_failure import delivery_failure @@ -165,12 +211,13 @@ async def commit_user_message(self, comms, target, body): task.add_done_callback( lambda done: done.exception() if not done.cancelled() else None ) - return None + return self._send_state except (HumanInitialUnknownError, HumanClaimBlockedError) as error: delivery_failure(error).present(self, body) - return None - self._enter_send_state(SentUndrawn(comms.root, sent)) - return sent + return self._send_state + accepted = SentUndrawn(comms.root, sent) + self._enter_send_state(accepted) + return accepted def settle_user_message(self) -> None: """Enable compose only when the sent message is drawn.""" @@ -182,7 +229,7 @@ def restore_user_message(self, body: str) -> None: if not self.prompt.text: self.prompt.text = body - def delivery_unknown(self, failure, body: str) -> None: + def delivery_unknown(self, failure: UnknownDeliveryFailure, body: str) -> None: self.restore_user_message(body) self._enter_send_state(SendUnknown(failure)) self.flash(self.send_block_reason, style="error") diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py index 40070efbe..2153d98f0 100644 --- a/src/toad/status_thread.py +++ b/src/toad/status_thread.py @@ -8,8 +8,6 @@ from pathlib import Path from typing import TYPE_CHECKING -from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation -from agent_comms.ui_model.observation import StoredRead, UnreadableHistory from agent_comms.ui_model.status import ThreadStatusModel from toad.core.events import ThreadRowsChanged @@ -31,7 +29,7 @@ class StatusThread(ABC): def for_root(cls, root: Path, name: str, access) -> StatusThread: if access.observation.observed().observes(root): return LocalStatusThread(root, name) - return ForeignStatusThread(root, name) + return OtherRouteStatusThread(root, name) @abstractmethod def bind(self, activity: ObservedThreadActivity) -> None: ... @@ -75,49 +73,9 @@ def observe(self, activity: ObservedThreadActivity) -> None: @dataclass(frozen=True) -class ForeignStatusThread(StatusThread): +class OtherRouteStatusThread(StatusThread): root: Path name: str def bind(self, activity: ObservedThreadActivity) -> None: - activity.show(ThreadStatusModel.pending(self.name)) - - def refresh(self, view: ThreadStatusOwner) -> None: - super().refresh(view) - view.run_worker( - self.read_foreign_thread(view), - group="foreign-thread-status", - exclusive=True, - ) - - async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: - read = await view.app.coordination_access.transcripts.thread_presentation( - str(self.root), self.name - ) - self.present_read(view, read) - - def present_read( - self, view: ThreadStatusOwner, read: StoredRead[ThreadPresentation] - ) -> None: - if view.status_thread() != self: - return - read.answer( - partial(self.present_value, view), - self.present_deferred, - partial(self.present_unreadable, view), - ) - - def present_value( - self, view: ThreadStatusOwner, presentation: ThreadPresentation - ) -> None: - view.thread_activity.show(ThreadStatusModel.known(self.name, presentation)) - view.foreign_thread_read(presentation) - - def present_deferred(self) -> None: - """An unfinished read leaves the previous row and presentation untouched.""" - - def present_unreadable( - self, view: ThreadStatusOwner, error: UnreadableHistory - ) -> None: - view.thread_activity.show(ThreadStatusModel.unreadable(self.name, error)) - view.foreign_thread_read(NoThreadPresentation()) + activity.show(ThreadStatusModel.other_route(self.name)) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index e351f94d2..7bd43fba7 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -8,7 +8,7 @@ from operator import attrgetter from pathlib import Path from time import monotonic, time_ns -from typing import TYPE_CHECKING, Any, Callable, Literal +from typing import TYPE_CHECKING, Any, Callable from acp import schema as acp_protocol from agent_comms.goal_presentation import IdleGoalExecution, ThreadGoalExecution @@ -19,7 +19,6 @@ from agent_comms.ui_model.goal import GoalDisplay from agent_comms.ui_model.message_category import MessageCategory, all_categories from rich.segment import Segment -from rich.text import Text as RichText from textual import containers, events, getters, on, work from textual._measurement import INDEPENDENT_HEIGHT, height_dependency from textual.actions import SkipAction @@ -66,6 +65,7 @@ TranscriptSourceWorkFinished, ) from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver +from toad.delivery_failure_view import DeliveryFailureView from toad.frame_presentation import Animated from toad.goal_interaction import GoalSession from toad.input_history import InputHistories @@ -378,7 +378,11 @@ def prepare_viewport(self) -> None: class ConversationSessionBinding( - MroDispatch, WorkspaceHosted, ThreadStatusOwner, containers.Vertical + MroDispatch, + WorkspaceHosted, + ThreadStatusOwner, + DeliveryFailureView, + containers.Vertical, ): """Source-bound state and reusable rich-surface lifecycle shared by conversations.""" @@ -417,9 +421,6 @@ def thread_observed(self, event: core_events.ThreadRowsChanged) -> None: self.arm_thread_read() read.settle(presentation).present(self.thread_presented) - def foreign_thread_read(self, presentation: ThreadPresentation) -> None: - self.thread_presented(presentation) - def thread_presented(self, presentation: ThreadPresentation) -> None: """A settled member owns whether this view has source work to publish.""" self.thread_presentation = presentation @@ -523,8 +524,6 @@ def mode_selection_available(self) -> bool: goal_execution: var[ThreadGoalExecution] = var(IdleGoalExecution()) - status: var[str | Content | RichText] = var("") - title = var("") def __init__( @@ -784,12 +783,10 @@ def watch_agent_binding(self, binding: AgentBinding) -> None: from toad.conversation_actions import ConversationAction -from toad.delivery_failure_view import DeliveryFailureView from toad.widget_actions import DeclaredWidgetActions class Conversation( - DeliveryFailureView, ConversationCommsHandlers, DeclaredWidgetActions, ConversationSessionBinding, @@ -834,7 +831,6 @@ class Conversation( contents = getters.query_one("#contents", Contents) window = getters.query_one(Window) cursor = getters.query_one(CursorContainer) - prompt = getters.query_one(Prompt) app = getters.app(ToadApp) @property @@ -1070,22 +1066,6 @@ def on_flash(self, event: messages.Flash) -> None: event.stop() self.flash(event.content, duration=event.duration, style=event.style) - def flash( - self, - content: str | Content, - *, - duration: float | None = None, - style: Literal["default", "warning", "error", "success"] = "default", - ) -> None: - """Flash a single-line message to the user. - - Args: - content: Content to flash. - style: A semantic style. - duration: Duration in seconds of the flash, or `None` to use default in settings. - """ - self.query_one(Flash).flash(content, duration=duration, style=style) - @property def terminal_action_state(self) -> bool | None: return None if self._terminal is None else True @@ -1541,10 +1521,6 @@ def ask( self.prompt.ask(ask) return ask - async def composer_read_only(self) -> bool: - """Native and CLI sessions compose to their exact thread, not a saved channel.""" - return False - async def command_target_context(self): from toad.screens.main import MainScreen from toad.target_commands import TargetContext diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index 66ecf5c6d..f270e4405 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -4,7 +4,6 @@ from dataclasses import dataclass -from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.status import ThreadStatusModel from textual.message import Message from textual.reactive import var @@ -102,8 +101,5 @@ def status_thread(self) -> StatusThread: def thread_observed(self, event: ThreadRowsChanged) -> None: """Consume the observed thread's presentation once per store change.""" - def foreign_thread_read(self, presentation: ThreadPresentation) -> None: - """Consume a foreign thread's presentation after its own read.""" - def refresh_thread_status(self) -> None: self.status_thread().refresh(self) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 39eef7a54..68572e81b 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -632,6 +632,13 @@ def agent_ready(self) -> bool: def text(self) -> str: return self.prompt_text_area.text + @text.setter + def text(self, text: str) -> None: + self.prompt_text_area.text = text + self.prompt_text_area.selection = Selection.cursor( + self.prompt_text_area.get_cursor_line_end_location() + ) + @property def supports_completion(self) -> bool: """This composer owns whether its current input mode admits completion.""" @@ -641,13 +648,6 @@ def supports_completion(self) -> bool: def active_popup(self) -> PromptPopup | None: return next((popup for popup in self.query(PromptPopup) if popup.is_open), None) - @text.setter - def text(self, text: str) -> None: - self.prompt_text_area.text = text - self.prompt_text_area.selection = Selection.cursor( - self.prompt_text_area.get_cursor_line_end_location() - ) - def watch_agent_binding(self) -> None: if self.is_mounted: self.sync_configuration() diff --git a/tests/app/test_foreign_status_read.py b/tests/app/test_foreign_status_read.py deleted file mode 100644 index e93cc31be..000000000 --- a/tests/app/test_foreign_status_read.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Deferred Core reads retain the actual mounted status display.""" - -import pytest -from agent_comms.bus_route_counts import RouteIndexRebuilding -from agent_comms.comms import Comms -from agent_comms.coordination_errors import CoordinationReadUnavailable -from agent_comms.errors import UnregisteredThreadError -from agent_comms.thread_presentation import NoThreadPresentation -from agent_comms.ui_model.observation import ReadDeferred, ReadPreparing -from agent_comms.ui_model.status import ThreadStatusModel -from textual.app import ComposeResult -from textual.containers import Vertical - -from toad.status_thread import ForeignStatusThread -from toad.widgets.observed_thread_activity import ( - ObservedThreadActivity, - ThreadStatusOwner, -) - - -class ForeignStatusSurface(ThreadStatusOwner, Vertical): - thread_activity: ObservedThreadActivity - - def __init__(self, source: ForeignStatusThread): - super().__init__() - self.source = source - self.thread_activity = ObservedThreadActivity() - - def compose(self) -> ComposeResult: - yield self.thread_activity - - def on_mount(self) -> None: - self.thread_activity.bind(self.source) - - def status_thread(self) -> ForeignStatusThread: - return self.source - - -@pytest.mark.parametrize( - "read", - [ - ReadDeferred(CoordinationReadUnavailable("busy snapshot")), - ReadPreparing(RouteIndexRebuilding(1, 2)), - ], -) -async def test_deferred_foreign_read_keeps_the_previous_status_row(workspace, read): - async with workspace.app() as ui: - app, _ = ui.textual - source = ForeignStatusThread(workspace.project / "foreign", "peer") - surface = ForeignStatusSurface(source) - await app.selected_session.mount(surface) - previous = ThreadStatusModel.pending("previous-status") - surface.thread_activity.show(previous) - source.present_read(surface, read) - assert surface.thread_activity.row is previous - - -async def test_foreign_missing_thread_keeps_its_member_through_read_and_status( - workspace, -): - foreign = Comms(workspace.roots.attempt / "missing-foreign-wire") - async with workspace.app() as ui: - app, _ = ui.textual - source = ForeignStatusThread(foreign.root, "gone") - surface = ForeignStatusSurface(source) - await app.selected_session.mount(surface) - read = await app.coordination_access.transcripts.thread_presentation( - str(foreign.root), "gone" - ) - assert read.raised() == NoThreadPresentation() - source.present_read(surface, read) - shown = surface.thread_activity.row.presentation - assert type(shown) is NoThreadPresentation - with pytest.raises(UnregisteredThreadError): - shown.require("gone") - - -async def test_foreign_read_presents_the_real_stored_thread(workspace): - foreign = Comms(workspace.roots.attempt / "foreign-wire") - foreign.registry.declare( - foreign.registry.new_thread( - "peer", frozenset(), str(workspace.project), created_at=10.0 - ) - ) - async with workspace.app() as ui: - app, _ = ui.textual - source = ForeignStatusThread(foreign.root, "peer") - surface = ForeignStatusSurface(source) - await app.selected_session.mount(surface) - await source.read_foreign_thread(surface) - assert surface.thread_activity.row == ThreadStatusModel.known( - "peer", foreign.views.thread_presentations(("peer",)).read("peer") - ) diff --git a/tests/app/test_other_route_status.py b/tests/app/test_other_route_status.py new file mode 100644 index 000000000..1101b8dbf --- /dev/null +++ b/tests/app/test_other_route_status.py @@ -0,0 +1,40 @@ +"""A mounted status row explains another route without reading its stores.""" + +from agent_comms.ui_model.status import ThreadStatusModel +from textual.app import ComposeResult +from textual.containers import Vertical + +from toad.status_thread import StatusThread +from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, +) + + +class OtherRouteStatusSurface(ThreadStatusOwner, Vertical): + def __init__(self, source: StatusThread): + super().__init__() + self.source = source + self.thread_activity = ObservedThreadActivity() + + def compose(self) -> ComposeResult: + yield self.thread_activity + + def on_mount(self) -> None: + self.thread_activity.bind(self.source) + + def status_thread(self) -> StatusThread: + return self.source + + +async def test_other_route_binds_its_status_without_starting_a_read(workspace): + other_root = workspace.roots.attempt / "not-selected" + async with workspace.app() as ui: + app, _ = ui.textual + source = StatusThread.for_root(other_root, "peer", app.coordination_access) + surface = OtherRouteStatusSurface(source) + await app.selected_session.mount(surface) + source.refresh(surface) + assert surface.thread_activity.row == ThreadStatusModel.other_route("peer") + assert not any(worker.node is surface for worker in app.workers) + assert not other_root.exists() From 3b7d79501932e1b299427dbc0395d92f43d483d3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:31:19 -0400 Subject: [PATCH 21/43] Use Core other-route status and its leaf error owner --- pyproject.toml | 2 +- src/toad/acp/agent_session.py | 3 ++- src/toad/acp/comms_updates.py | 2 +- src/toad/comms_root.py | 3 ++- src/toad/session_navigation.py | 5 +++-- src/toad/target_commands.py | 5 +++-- src/toad/thread_actions.py | 3 ++- src/toad/widgets/observed_thread_activity.py | 2 +- uv.lock | 4 ++-- 9 files changed, 17 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56fb7fa2e..d2f24e93e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = "main" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "94ee337b0c6600b00e876dd9c732b0bb30fbff16" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", branch = "main" } diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 2cd668e03..04a3e70c4 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -13,9 +13,10 @@ from agent_comms.acp_failure import ACPFailure from agent_comms.child_process import join_retirement from agent_comms.declared_family import DeclaredFamily +from agent_comms.errors import RouteChanged from agent_comms.input_attempt import NotSentInput from agent_comms.owner_protocol import IncompatibleOwner -from agent_comms.route_selection import RouteChanged, RouteSelection +from agent_comms.route_selection import RouteSelection from agent_comms.session_load import ( EnsuringSessionLoadPlan, ExistingSessionLoadPlan, diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 4478f4061..b91ddcc36 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -134,7 +134,7 @@ def input_started(self, update: InputStartedUpdate) -> None: def coordination_changed(self, update: CoordinationChangedUpdate) -> None: from pathlib import Path - from agent_comms.route_selection import RouteChanged + from agent_comms.errors import RouteChanged agent = self.agent root = agent.controller.transcripts.access.launch_root.path diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 9fa8bde80..1760e04ac 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -22,10 +22,11 @@ from agent_comms.coordination_errors import StaleRevision from agent_comms.display_references import PendingReferences, UnavailableReferences +from agent_comms.errors import RouteChanged from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.presentation import MessageNotification, NoRevision, ReadState from agent_comms.revision_views import ExternalTranscriptsView, StatusView -from agent_comms.route_selection import RouteChanged, RouteSelection, run_selected_write +from agent_comms.route_selection import RouteSelection, run_selected_write from agent_comms.ui_model.observation import ( NotificationsObserved, NotificationsUnreadable, diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index d5b2be058..b8ca6d1ad 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -11,12 +11,13 @@ from agent_comms.errors import ( RelationViolationError, + RouteChanged, UnknownChannelError, UnregisteredThreadError, ) from agent_comms.presentation import NoRevision, ReadState from agent_comms.revision_views import RegistrationsView -from agent_comms.route_selection import RouteChanged, root_is_current +from agent_comms.route_selection import root_is_current from agent_comms.thread_identity import ThreadIncarnation from toad.core import events as core_events @@ -418,7 +419,7 @@ async def _retire_requested_views(self) -> None: await self.retire_missing() async def retire_missing(self) -> None: - from agent_comms.route_selection import RouteChanged, RouteSelection + from agent_comms.route_selection import RouteSelection # Borrow each open entry, including hidden views. Core decides # which of their sources no longer exist; Toad only closes the views. diff --git a/src/toad/target_commands.py b/src/toad/target_commands.py index e75ae05ba..eacabbff4 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -11,7 +11,8 @@ from agent_comms.comms import Comms from agent_comms.channel_targets import ChannelSelection, CommandTarget, NoChannel -from agent_comms.route_selection import RouteChanged, root_is_current +from agent_comms.errors import RouteChanged +from agent_comms.route_selection import root_is_current from toad.slash_command import CommandPresentation, LocalCommand, SlashCommand from agent_comms.cli_commands import CliCommand, TargetAction from toad.thread_actions import ThreadAction @@ -42,7 +43,7 @@ def title(self): def current(self): if not root_is_current(self.comms.root): - raise RouteChanged('Comms route changed; reopen this view') + raise RouteChanged(RouteChanged.reader_sentence) return self def available_actions(self): diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index b430fc847..8529a01f4 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -10,8 +10,9 @@ from agent_comms.cli_commands import TargetAction, TargetEdit, TargetBatchResult, TargetFailed from agent_comms.cli_result import CliResult +from agent_comms.errors import RouteChanged from agent_comms.native_pi import NativePiUnavailable -from agent_comms.route_selection import RouteChanged, RouteSelection +from agent_comms.route_selection import RouteSelection if TYPE_CHECKING: from toad.app import ToadApp diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index f270e4405..f4f5e2fee 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -1,4 +1,4 @@ -"""An open thread's status line, observed locally or read from its foreign root.""" +"""An open thread's status line, observed locally or marked on another route.""" from __future__ import annotations diff --git a/uv.lock b/uv.lock index cde24cba8..c346a48f3 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main#776f758e4f504ef280447b65c53ddd01efa4e404" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=94ee337b0c6600b00e876dd9c732b0bb30fbff16#94ee337b0c6600b00e876dd9c732b0bb30fbff16" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, @@ -302,7 +302,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main" }, + { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=94ee337b0c6600b00e876dd9c732b0bb30fbff16" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, From 2bde9cb814b068d9af7f27b3448bf9bf4807f4d2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:37:22 -0400 Subject: [PATCH 22/43] Follow the declared route error and resolved Core source --- .github/workflows/ci.yml | 24 ++++++++++++++---------- pyproject.toml | 2 +- src/toad/acp/agent_session.py | 4 ++-- src/toad/acp/comms_updates.py | 4 ++-- src/toad/comms_root.py | 10 +++++----- src/toad/session_navigation.py | 6 +++--- src/toad/target_commands.py | 4 ++-- src/toad/thread_actions.py | 4 ++-- uv.lock | 4 ++-- 9 files changed, 33 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9a5fab4d..e0ee1b588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,12 +37,14 @@ jobs: - name: Read the declared Core source id: core run: | - python - <<'PY' - import os, pathlib, tomllib - source = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['tool']['uv']['sources']['agent-comms'] + .venv/bin/python - <<'PY' + import os + from importlib.metadata import distribution + from agent_comms.installer.runtime_resolution import GitRecord + source = GitRecord.model_validate_json(distribution('agent-comms').read_text('direct_url.json')) with open(os.environ['GITHUB_OUTPUT'], 'a') as output: - print('repository=' + source['git'].removeprefix('https://github.com/').removesuffix('.git'), file=output) - print('revision=' + source['branch'], file=output) + print('repository=' + source.url.removeprefix('https://github.com/').removesuffix('.git'), file=output) + print('revision=' + source.revision(), file=output) PY - uses: actions/checkout@v4 with: @@ -85,11 +87,13 @@ jobs: env: STOCK_PI_VERSION: ${{ inputs.stock_pi_version }} run: | - python - <<'PYCORE' - import pathlib, subprocess, tomllib - source = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['tool']['uv']['sources']['agent-comms'] - subprocess.run(['git', 'clone', '--filter=blob:none', '--no-checkout', source['git'], '.artifacts/core'], check=True) - subprocess.run(['git', '-C', '.artifacts/core', 'checkout', '--detach', source['branch']], check=True) + .venv/bin/python - <<'PYCORE' + import subprocess + from importlib.metadata import distribution + from agent_comms.installer.runtime_resolution import GitRecord + source = GitRecord.model_validate_json(distribution('agent-comms').read_text('direct_url.json')) + subprocess.run(['git', 'clone', '--filter=blob:none', '--no-checkout', source.url, '.artifacts/core'], check=True) + subprocess.run(['git', '-C', '.artifacts/core', 'checkout', '--detach', source.revision()], check=True) subprocess.run(['git', '-C', '.artifacts/core', 'submodule', 'update', '--init', '--recursive'], check=True) PYCORE npm install --prefix .artifacts/pi-stock --install-strategy=nested "@earendil-works/pi-coding-agent@$STOCK_PI_VERSION" diff --git a/pyproject.toml b/pyproject.toml index d2f24e93e..ccf6944d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "94ee337b0c6600b00e876dd9c732b0bb30fbff16" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "7a423460636f72a9e910d1b0a7d42fcf92d6c5b1" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", branch = "main" } diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 04a3e70c4..d65a55875 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -13,7 +13,7 @@ from agent_comms.acp_failure import ACPFailure from agent_comms.child_process import join_retirement from agent_comms.declared_family import DeclaredFamily -from agent_comms.errors import RouteChanged +from agent_comms.errors import RouteChangedError from agent_comms.input_attempt import NotSentInput from agent_comms.owner_protocol import IncompatibleOwner from agent_comms.route_selection import RouteSelection @@ -382,7 +382,7 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: RouteSelection.capture, self.agent.controller.transcripts.access.launch_root.path, ) - except RouteChanged as error: + except RouteChangedError as error: self.startup_failed(f"Reconnect blocked before restart: {error}") return await self.agent.stop() diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index b91ddcc36..6fa06de30 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -134,12 +134,12 @@ def input_started(self, update: InputStartedUpdate) -> None: def coordination_changed(self, update: CoordinationChangedUpdate) -> None: from pathlib import Path - from agent_comms.errors import RouteChanged + from agent_comms.errors import RouteChangedError agent = self.agent root = agent.controller.transcripts.access.launch_root.path if Path(update.wire_root).resolve() != root.resolve(): - raise RouteChanged("ACP owner reported a different coordination root") + raise RouteChangedError("ACP owner reported a different coordination root") agent.coordination = update agent.project_root_path = Path(update.worktree) agent.context_measurement = ContextMeasurement.saved(update.context_usage) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 1760e04ac..63facc2e8 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -22,7 +22,7 @@ from agent_comms.coordination_errors import StaleRevision from agent_comms.display_references import PendingReferences, UnavailableReferences -from agent_comms.errors import RouteChanged +from agent_comms.errors import RouteChangedError from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.presentation import MessageNotification, NoRevision, ReadState from agent_comms.revision_views import ExternalTranscriptsView, StatusView @@ -134,7 +134,7 @@ def attach(self, access, previous): for _, waiter in waiters: if not waiter.done(): waiter.set_exception( - RouteChanged("Comms service changed before the sidebar was read") + RouteChangedError("Comms service changed before the sidebar was read") ) access.revision = NoRevision() access.sidebar_interest = access.sidebar_current = None @@ -307,7 +307,7 @@ def references_for(self, root): async def current_references(self, access, root): try: await access.current_sidebar() - except RouteChanged: + except RouteChangedError: return await access.observation.current_references(access, root) return access.reference_context(root) @@ -394,7 +394,7 @@ def open_current(self) -> Observing | FailedObservation: def require(self, selected: RouteSelection) -> Observing: """The service for ``selected``; a new selection opens it and starts its observation.""" if RouteSelection.capture() != selected: - raise RouteChanged("Comms route changed before the operation") + raise RouteChangedError("Comms route changed before the operation") with self.opening: return self.observation.require(self, selected) @@ -409,7 +409,7 @@ def open_selected(self, selected: RouteSelection) -> Observing: service.root.resolve() != selected.root or RouteSelection.capture() != selected ): - raise RouteChanged("Comms route changed while opening the service") + raise RouteChangedError("Comms route changed while opening the service") observed = Observing( selected, service, diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index b8ca6d1ad..0dd82b1f3 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -11,7 +11,7 @@ from agent_comms.errors import ( RelationViolationError, - RouteChanged, + RouteChangedError, UnknownChannelError, UnregisteredThreadError, ) @@ -285,7 +285,7 @@ async def history( comms, owner_mode, me, conversation, source.coordination_root ) ) - except (UnknownChannelError, UnregisteredThreadError, RouteChanged) as error: + except (UnknownChannelError, UnregisteredThreadError, RouteChangedError) as error: app.show_error(error, "Comms target unavailable") return app.selected_mode if prepared is None: @@ -449,7 +449,7 @@ def read(): retired = service.views.retired_views(threads, channels) if RouteSelection.capture(root) != selected: continue # Replaced while reading; the next observation reads it. - except RouteChanged: + except RouteChangedError: continue # The next observation reads the replacement route. answers[root] = service, retired return answers diff --git a/src/toad/target_commands.py b/src/toad/target_commands.py index eacabbff4..81ee96c6d 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -11,7 +11,7 @@ from agent_comms.comms import Comms from agent_comms.channel_targets import ChannelSelection, CommandTarget, NoChannel -from agent_comms.errors import RouteChanged +from agent_comms.errors import RouteChangedError from agent_comms.route_selection import root_is_current from toad.slash_command import CommandPresentation, LocalCommand, SlashCommand from agent_comms.cli_commands import CliCommand, TargetAction @@ -43,7 +43,7 @@ def title(self): def current(self): if not root_is_current(self.comms.root): - raise RouteChanged(RouteChanged.reader_sentence) + raise RouteChangedError(RouteChangedError.reader_sentence) return self def available_actions(self): diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index 8529a01f4..a4573baac 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -10,7 +10,7 @@ from agent_comms.cli_commands import TargetAction, TargetEdit, TargetBatchResult, TargetFailed from agent_comms.cli_result import CliResult -from agent_comms.errors import RouteChanged +from agent_comms.errors import RouteChangedError from agent_comms.native_pi import NativePiUnavailable from agent_comms.route_selection import RouteSelection @@ -139,7 +139,7 @@ async def prepare(): fields = await ctx.app.preparation.run_thread( lambda: asyncio.run(definition.prepare_fields())) ctx.current() - except (NativePiUnavailable, RouteChanged) as error: + except (NativePiUnavailable, RouteChangedError) as error: ctx.app.show_error(error, definition.label) return if ctx.app.screen is screen: diff --git a/uv.lock b/uv.lock index c346a48f3..e6d010305 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=94ee337b0c6600b00e876dd9c732b0bb30fbff16#94ee337b0c6600b00e876dd9c732b0bb30fbff16" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=7a423460636f72a9e910d1b0a7d42fcf92d6c5b1#7a423460636f72a9e910d1b0a7d42fcf92d6c5b1" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, @@ -302,7 +302,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=94ee337b0c6600b00e876dd9c732b0bb30fbff16" }, + { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=7a423460636f72a9e910d1b0a7d42fcf92d6c5b1" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, From 738299243101c778d4eba2c075007b1595b862ed Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 12:07:26 -0400 Subject: [PATCH 23/43] Own agent startup selection once on the application binding --- src/toad/agent.py | 4 +- src/toad/agent_binding.py | 4 +- src/toad/app.py | 8 +-- src/toad/cli.py | 26 ++++---- src/toad/navigation_preparation.py | 3 +- src/toad/screens/main.py | 2 +- src/toad/screens/welcome.py | 3 +- src/toad/session_presentation.py | 8 +-- src/toad/session_scope.py | 62 +++++++++++++------ src/toad/widgets/conversation.py | 6 +- src/toad/widgets/prompt.py | 4 +- tests/app/test_agent_binding.py | 24 ++++++- tests/app/test_agent_startup_failure.py | 5 +- tests/app/test_context_usage.py | 3 +- .../test_local_command_before_agent_ready.py | 5 +- tests/app/test_markdown_file_links.py | 3 +- tests/app/test_permission_across_tabs.py | 7 ++- tests/app/test_prompt_routing.py | 7 ++- tests/app/test_session_scope.py | 4 +- tests/app/test_submissions_in_a_row.py | 3 +- tests/app/test_welcome_view.py | 5 +- tests/native/test_native_catalog_launch.py | 3 +- .../test_thread_open_during_slow_load.py | 3 +- .../core_adapters/human_input_observer.py | 3 +- tests/support/failed_close_app.py | 3 +- tests/support/first_frame_app.py | 5 +- tests/support/native.py | 3 +- tests/support/observed_app.py | 3 +- tests/support/ui.py | 3 +- tests/support/workspace.py | 6 +- tests/textual/test_acp_client_requests.py | 3 +- tests/textual/test_acp_log_link.py | 3 +- ..._application_actions_in_the_running_app.py | 5 +- tests/textual/test_local_echo.py | 3 +- tests/textual/test_plan_display.py | 5 +- tests/textual/test_prompt_completion.py | 13 ++-- tests/textual/test_prompt_shell_mode.py | 3 +- .../test_quit_with_unsaved_settings.py | 3 +- tests/textual/test_session_tool_calls.py | 3 +- ...rkspace_tabs_share_one_agent_connection.py | 4 +- 40 files changed, 170 insertions(+), 103 deletions(-) diff --git a/src/toad/agent.py b/src/toad/agent.py index 2775f7505..e0e02bccf 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -5,13 +5,13 @@ from textual.content import Content from toad.acp.status import StopReason -from toad.agent_binding import AgentBinding +from toad.agent_binding import AgentConnection from toad.agent_presentation import LocalAgentPresentation from toad.conversation_turn import NoTurn from toad.core.events import CoreEventStream -class AgentBase(AgentBinding): +class AgentBase(AgentConnection): """Base class for an 'agent'.""" def __init__(self, project_root: Path) -> None: diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 12fa3b9e8..3b102c595 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -8,7 +8,7 @@ from toad.acp.agent_session import UnopenedSessionDisposition -class AgentBinding(ABC): +class AgentConnection(ABC): def thread_read(self): from toad.acp.comms_updates import NoPendingThreadRead return NoPendingThreadRead() @@ -45,7 +45,7 @@ def accepts_message(self, message) -> bool: ... def matches_agent(self, agent) -> bool: ... -class NoAgentBound(AgentBinding): +class NoAgentConnection(AgentConnection): """A real saved view before it acquires any native connection.""" ready = False automatic_titles = True diff --git a/src/toad/app.py b/src/toad/app.py index 1aa531abe..760f8c50e 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -34,7 +34,7 @@ from toad.core import session_requests from toad.core_event_carrier import CoreEventReceiver from agent_comms.mro_dispatch import handles -from toad.agent_schema import AgentDefinition +from toad.session_scope import AgentBinding, NoAgent from toad.render_backend import Renderer from toad.work_preparation import PreparationRuntime from toad.navigation_preparation import ( @@ -148,7 +148,7 @@ class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown def __init__( self, - agent_data: AgentDefinition | None = None, + agent_binding: AgentBinding = NoAgent(), project_dir: str | None = None, mode: str | None = None, agent_session_id: str | None = None, @@ -156,7 +156,7 @@ def __init__( """Toad app. Args: - agent_data: Agent data to run. + agent_binding: The agent selected at startup. project_dir: Project directory. mode: Initial mode. agent: Agent identity or shor name. @@ -171,7 +171,7 @@ def __init__( self.background_render_slots = asyncio.Semaphore(1) self._background_render_tasks: set[asyncio.Task[object]] = set() self.navigation_reader = NavigationReader() - self.agent_data = agent_data + self.agent_binding = agent_binding self.application = ApplicationLifetime(self, agent_session_id) self.clipboard_transport = Clipboard.for_platform() diff --git a/src/toad/cli.py b/src/toad/cli.py index 237929e12..005a340bd 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -5,9 +5,10 @@ import click +from toad.session_scope import AgentAttached, AgentBinding, NoAgent + if TYPE_CHECKING: from toad.app import ToadApp - from toad.agent_schema import AgentDefinition def persist_terminal_failure(error: Exception) -> None: @@ -52,7 +53,9 @@ def check_directory(path: str) -> None: sys.exit(-1) -async def get_agent_data(launch_agent) -> AgentDefinition | None: +async def get_agent_binding(launch_agent: str) -> AgentBinding: + if not launch_agent: + return NoAgent() launch_agent = launch_agent.lower() from toad.agents import read_agents, AgentReadError @@ -70,7 +73,8 @@ async def get_agent_data(launch_agent) -> AgentDefinition | None: launch_agent = agent_data.identity break - return agents.get(launch_agent) + selected = agents.get(launch_agent) + return NoAgent() if selected is None else AgentAttached(selected) class DefaultCommandGroup(click.Group): @@ -133,18 +137,14 @@ def run(project_dir: str = ".", agent: str = "1"): check_directory(project_dir) - if agent: - import asyncio - - agent_data = asyncio.run(get_agent_data(agent)) - else: - agent_data = None + import asyncio from toad.app import ToadApp + binding = asyncio.run(get_agent_binding(agent)) app = ToadApp( - mode=None if agent_data else "store", - agent_data=agent_data, + mode=binding.startup_mode, + agent_binding=binding, project_dir=project_dir, ) run_terminal(app) @@ -202,7 +202,9 @@ def acp( from toad.app import ToadApp app = ToadApp( - agent_data=agent_data, project_dir=project_dir, agent_session_id=session_id, + agent_binding=AgentAttached(agent_data), + project_dir=project_dir, + agent_session_id=session_id, ) run_terminal(app) diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index 3c7dcc521..0cc4df6b0 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -19,7 +19,6 @@ from toad.conversation_kind import ConversationKind from toad.owner_preparation import ExistingOwnerPlan -from toad.session_scope import NoAgent from toad.session_tracker import CommsViewKey ResultT = TypeVar("ResultT") @@ -92,7 +91,7 @@ def launch_screen(self, app): self.project, self.root, self.thread.incarnation, - NoAgent().attached(app.agent_data), + app.agent_binding, ) self.bind(screen) return screen diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 81767a00b..c52ae2f15 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -319,7 +319,7 @@ def opening(cls, request, origin, app) -> "MainScreen": # The request has only a name; bind the wire after its metadata read. screen = cls( request.project, - origin.source.agent_for_opening(app.agent_data).for_session(request.target), + origin.source.agent_for_opening(app.agent_binding).for_session(request.target), ) screen.content = NavigatedOpeningContent() return screen diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 3559265a6..fac8d8414 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -211,8 +211,7 @@ def __init__(self) -> None: self.project_path = app.project_dir def compose(self) -> ComposeResult: - selected = self.app.agent_data - agent = f"{selected.name} · " if selected is not None else "" + agent = self.app.agent_binding.welcome_prefix with VerticalScroll(can_focus=False): yield WelcomeTitle() yield Static( diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 63ec6a87a..006086903 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -14,7 +14,7 @@ from textual.widget import Widget from textual.widgets.text_area import TextAreaState -from toad.agent_binding import NoAgentBound +from toad.agent_binding import NoAgentConnection from toad.agent_surface import AttachedSurfaceBinding from toad.conversation_kind import DmConversation from toad.input_history import InputHistories @@ -73,7 +73,7 @@ class SessionSurfaceLifetime(ABC): @property def operational_binding(self): - return NoAgentBound() + return NoAgentConnection() @abstractmethod def compose_content(self, screen: MainScreen) -> Widget: ... @@ -107,7 +107,7 @@ class OperationalSessionSources: """Own existing operational resources while the view is detached.""" def __init__(self) -> None: - self.agent_binding = NoAgentBound() + self.agent_binding = NoAgentConnection() self.shell = None self.load_plan = EnsuringSessionLoadPlan() @@ -225,7 +225,7 @@ async def detach(self, conversation: Conversation, screen: MainScreen) -> None: self.agent_binding.detach_surface(conversation) async def close(self) -> None: - agent, self.agent_binding = self.agent_binding, NoAgentBound() + agent, self.agent_binding = self.agent_binding, NoAgentConnection() shell, self.shell = self.shell, None async with AsyncResourceRelease() as release: if shell is not None: diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 7a2f45ce3..d03e4527b 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -16,6 +16,7 @@ from agent_comms.ui_model.observation import ReadTranscript, ReadTranscriptUpdate from toad.app_node import ToadNode +from toad.constants import WELCOME from toad.core.events import ThreadRowsChanged from toad.core_event_carrier import ThreadCallbackSubscription from toad.navigation_context import NavigationOwner @@ -46,6 +47,9 @@ def original_threads( self, identity: SessionIdentity ) -> tuple[tuple[str, ThreadIncarnation], ...]: ... + @abstractmethod + def matches_session(self, access, session_id: str) -> bool: ... + @abstractmethod def status_thread(self, access) -> StatusThread: ... @@ -78,6 +82,13 @@ def original_threads( ) -> tuple[tuple[str, ThreadIncarnation], ...]: return ((self.root, identity.thread_incarnation()),) + def matches_session(self, access, session_id: str) -> bool: + service = access.require(RouteSelection.capture(self.root)).service + return ( + service.registry.canonical_name(session_id) + == service.registry.require(self.name).name + ) + def status_thread(self, access) -> StatusThread: return StatusThread.for_root(Path(self.root), self.name, access) @@ -124,6 +135,9 @@ def original_threads( ) -> tuple[tuple[str, ThreadIncarnation], ...]: return () + def matches_session(self, access, session_id: str) -> bool: + return False + def status_thread(self, access) -> StatusThread: return NoStatusThread() @@ -249,15 +263,21 @@ def resolve_thread(self) -> str: service = self.app.coordination_access.require(selected).service return identity.resolve_thread(service.registry, self.project_root) - def agent_for_opening(self, default: AgentDefinition | None) -> AgentBinding: - return NoAgent().attached(default) + def agent_for_opening(self, default: AgentBinding) -> AgentBinding: + return default class AgentBinding(ABC): """Known native launch state carries construction and attachment behavior.""" + startup_mode: ClassVar[str] + + @property @abstractmethod - def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: ... + def welcome_prefix(self) -> str: ... + + @abstractmethod + def attached(self, app_binding: AgentBinding) -> AgentBinding: ... @abstractmethod def for_session(self, session_id: str) -> AgentBinding: ... @@ -281,10 +301,14 @@ def initial_identity(self, empty: SessionIdentity) -> SessionIdentity: @dataclass(frozen=True) class NoAgent(AgentBinding): - def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: - if app_agent is None: - return self - return AgentAttached(app_agent) + startup_mode = "store" + + @property + def welcome_prefix(self) -> str: + return "" + + def attached(self, app_binding: AgentBinding) -> AgentBinding: + return app_binding def for_session(self, session_id: str) -> AgentBinding: return self @@ -304,8 +328,13 @@ def matches_launch( @dataclass(frozen=True) class AgentAttached(AgentBinding): agent: AgentDefinition + startup_mode = WELCOME + + @property + def welcome_prefix(self) -> str: + return f"{self.agent.name} · " - def attached(self, app_agent: AgentDefinition | None) -> AgentBinding: + def attached(self, app_binding: AgentBinding) -> AgentBinding: return self def for_session(self, session_id: str) -> AgentBinding: @@ -327,15 +356,10 @@ def matches_launch( ) def matches_session(self, origin: ViewSessionOrigin, session_id: str) -> bool: - if origin.presentation.operational_binding.matches_session(session_id): - return True - root = origin.coordination_root - return ( - root is not None - and origin.app.coordination_access.require( - RouteSelection.capture(root) - ).service.registry.canonical_name(session_id) - == origin.resolve_thread() + return origin.presentation.operational_binding.matches_session( + session_id + ) or origin.transcript_scope().matches_session( + origin.app.coordination_access, session_id ) @@ -362,11 +386,11 @@ class ViewSessionOrigin(ProjectSessionOrigin): _agent_binding: AgentBinding - def agent_for_opening(self, default: AgentDefinition | None) -> AgentBinding: + def agent_for_opening(self, default: AgentBinding) -> AgentBinding: return self._agent_binding.attached(default) def attach_agent_definition(self) -> AgentBinding: - self._agent_binding = self._agent_binding.attached(self.app.agent_data) + self._agent_binding = self._agent_binding.attached(self.app.agent_binding) return self._agent_binding def use_mailbox(self) -> None: diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 7bd43fba7..85573aba0 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -39,7 +39,7 @@ from toad.acp.comms_updates import NoPendingThreadRead from toad.acp.status import StopReason from toad.agent import AgentBase -from toad.agent_binding import AgentBinding, NoAgentBound +from toad.agent_binding import AgentConnection, NoAgentConnection from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.app import ToadApp @@ -504,7 +504,7 @@ def _turn_changed(self, owner: TurnOwner) -> None: _shell: var[Shell | None] = var(None) - agent_binding: var[AgentBinding] = var(NoAgentBound, bindings=True) + agent_binding: var[AgentConnection] = var(NoAgentConnection, bindings=True) agent_info: var[Content] = var(Content()) model_history_scope = var("") @@ -667,7 +667,7 @@ async def release_native_session(self) -> None: async with AsyncResourceRelease() as release: release.callback(self.take_initial_prompt) release.callback( - self.set_reactive, Conversation.agent_binding, NoAgentBound() + self.set_reactive, Conversation.agent_binding, NoAgentConnection() ) await self._release_source_resources() diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 68572e81b..8183a4abf 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -27,7 +27,7 @@ from ui_theme.scheme import PrimaryColour, PromptToneColour, SecondaryColour from toad import messages -from toad.agent_binding import NoAgentBound +from toad.agent_binding import NoAgentConnection from toad.conversation_turn import ConversationTurn from toad.core import input_events from toad.core.input_events import UserInputSubmitted @@ -593,7 +593,7 @@ class Prompt(TonePaint, containers.VerticalGroup): display_directory = var("") agent_info = var(Content("")) _ask: var[Ask | None] = var(None) - agent_binding = var(NoAgentBound) + agent_binding = var(NoAgentConnection) model_history_scope = var("") status: var[str | Content | RichText] = var("") diff --git a/tests/app/test_agent_binding.py b/tests/app/test_agent_binding.py index 9a48fea43..db2612d1a 100644 --- a/tests/app/test_agent_binding.py +++ b/tests/app/test_agent_binding.py @@ -2,11 +2,31 @@ from support.agents import peer +from toad.agents import read_agents +from toad.cli import get_agent_binding from toad.screens.main import MainScreen -from toad.session_scope import AgentSession +from toad.session_scope import AgentAttached, AgentSession, NoAgent from toad.thread_identity import NoThreadIdentity +async def test_startup_binding_is_decoded_once_and_retained_by_the_app(workspace): + definition = next(iter((await read_agents()).values())) + selected = AgentAttached(definition) + for spelling, expected in ( + ("", NoAgent()), + ("no-such-startup-agent", NoAgent()), + (definition.identity.upper(), selected), + (definition.short_name.upper(), selected), + ): + assert await get_agent_binding(spelling) == expected + async with workspace.app(binding=selected, session=False) as ui: + app, _ = ui.textual + assert app.agent_binding is selected + assert NoAgent().attached(app.agent_binding) is selected + assert selected.attached(NoAgent()) is selected + assert ui.tabs() == () + + async def test_no_agent_does_not_match_a_launch(workspace): view = MainScreen(workspace.project) assert not view.matches_launch("not-bound", "not-bound") @@ -17,7 +37,7 @@ async def test_attached_agent_matches_its_live_session_and_relaunch_reuses_the_v workspace, ): definition = peer("echo") - async with workspace.app(agent=definition) as ui: + async with workspace.app(binding=AgentAttached(definition)) as ui: app, _ = ui.textual await ui.until_agent_ready() original = app.selected_session diff --git a/tests/app/test_agent_startup_failure.py b/tests/app/test_agent_startup_failure.py index 55cc276f9..2ee84fda7 100644 --- a/tests/app/test_agent_startup_failure.py +++ b/tests/app/test_agent_startup_failure.py @@ -1,4 +1,5 @@ """An agent that cannot start is reported in the conversation and never accepts input.""" +from toad.session_scope import AgentAttached import sys @@ -7,7 +8,7 @@ async def test_a_process_that_exits_during_startup_is_reported(workspace): agent = command(sys.executable, "-c", "import sys; sys.stdin.readline() # exits_during_startup") - async with workspace.app(size=(139, 25), agent=agent) as ui: + async with workspace.app(size=(139, 25), binding=AgentAttached(agent)) as ui: await ui.until_shows("ACP process closed", seconds=20) assert not ui.agent_ready() await ui.until(lambda: not workspace.child_processes("exits_during_startup"), why="the agent process to be gone") @@ -15,7 +16,7 @@ async def test_a_process_that_exits_during_startup_is_reported(workspace): async def test_a_refused_handshake_is_reported_as_not_sent(workspace): # The refusing process stays attached: a later owner may accept the same connection. - async with workspace.app(size=(139, 25), agent=peer("refuses_initialize")) as ui: + async with workspace.app(size=(139, 25), binding=AgentAttached(peer("refuses_initialize"))) as ui: await ui.until_shows("AGENT_REFUSED_TO_START", seconds=20) assert ui.shows("Not sent") assert not ui.agent_ready() diff --git a/tests/app/test_context_usage.py b/tests/app/test_context_usage.py index 451c344e3..bf7c66068 100644 --- a/tests/app/test_context_usage.py +++ b/tests/app/test_context_usage.py @@ -1,4 +1,5 @@ """The status line shows context use as reported; a zero after compaction means unknown, not empty.""" +from toad.session_scope import AgentAttached from agent_comms.context_reading import ContextUsage, UnmeasuredContext from toad.acp.context_measurement import ContextMeasurement @@ -36,7 +37,7 @@ async def test_usage_updates_reach_the_status_line(workspace): async def test_usage_measured_while_another_tab_is_shown_appears_on_return(workspace): - async with workspace.app(size=(100, 35), agent=peer("scripted")) as ui: + async with workspace.app(size=(100, 35), binding=AgentAttached(peer("scripted"))) as ui: await ui.until_agent_ready() first = ui.selected_tab late = {"update": usage(40000)} diff --git a/tests/app/test_local_command_before_agent_ready.py b/tests/app/test_local_command_before_agent_ready.py index ced1c3244..01cdadd73 100644 --- a/tests/app/test_local_command_before_agent_ready.py +++ b/tests/app/test_local_command_before_agent_ready.py @@ -1,4 +1,5 @@ """A declared local command shows its hint and runs while the agent is still starting.""" +from toad.session_scope import AgentAttached from support.agents import peer from toad.slash_command import NoArgumentsCommand @@ -16,8 +17,8 @@ async def apply(self, conversation): async def test_declared_local_command_runs_before_the_agent_is_ready(workspace): hold = workspace.project / "hold-acp-startup" hold.touch() - async with workspace.app(size=(130, 44), agent=peer("completion", name="Physical completion peer", - identity="completion-acceptance")) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(peer("completion", name="Physical completion peer", + identity="completion-acceptance"))) as ui: try: assert not ui.agent_ready() await ui.write_draft(CursorProofCommand().command + " ") diff --git a/tests/app/test_markdown_file_links.py b/tests/app/test_markdown_file_links.py index 134726770..d8189c16c 100644 --- a/tests/app/test_markdown_file_links.py +++ b/tests/app/test_markdown_file_links.py @@ -1,4 +1,5 @@ """A file named in an agent answer is a link to the project file; clicking it opens the file's preview and returning keeps the draft.""" +from toad.session_scope import AgentAttached from support.agents import peer @@ -15,7 +16,7 @@ def file_links(ui, text): async def test_file_link_in_answer_opens_preview_and_return_keeps_draft(workspace): target = workspace.project / "file.md" target.write_text("# REAL_MARKDOWN_FILE_PREVIEW\n\n```python\nvalue = 42\n```\n") - async with workspace.app(size=(110, 36), agent=peer("echo")) as ui: + async with workspace.app(size=(110, 36), binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() owner = ui.selected_tab await ui.write_draft(ANSWER_SOURCE) diff --git a/tests/app/test_permission_across_tabs.py b/tests/app/test_permission_across_tabs.py index 1503995c8..2a0bb11c2 100644 --- a/tests/app/test_permission_across_tabs.py +++ b/tests/app/test_permission_across_tabs.py @@ -1,4 +1,5 @@ """A permission question asked while its tab is hidden is there on return and its answer reaches the agent.""" +from toad.session_scope import AgentAttached from support.agents import peer, script @@ -8,7 +9,7 @@ def answers(workspace): async def test_permission_asked_in_the_shown_tab_is_answered(workspace): - async with workspace.app(size=(110, 40), agent=peer("scripted")) as ui: + async with workspace.app(size=(110, 40), binding=AgentAttached(peer("scripted"))) as ui: await ui.until_agent_ready() await ui.write_draft(script({"ask": "PERMISSION_FOR_EDIT"})) await ui.press("enter") @@ -19,7 +20,7 @@ async def test_permission_asked_in_the_shown_tab_is_answered(workspace): async def test_permission_asked_while_another_tab_is_shown_is_answered_on_return(workspace): - async with workspace.app(size=(110, 40), agent=peer("scripted")) as ui: + async with workspace.app(size=(110, 40), binding=AgentAttached(peer("scripted"))) as ui: await ui.until_agent_ready() first = ui.selected_tab await ui.write_draft(script({"wait": "hidden"}, {"ask": "PERMISSION_WHILE_HIDDEN"})) @@ -36,7 +37,7 @@ async def test_permission_asked_while_another_tab_is_shown_is_answered_on_return async def test_leaving_and_returning_keeps_a_pending_permission_question(workspace): - async with workspace.app(size=(110, 40), agent=peer("scripted")) as ui: + async with workspace.app(size=(110, 40), binding=AgentAttached(peer("scripted"))) as ui: await ui.until_agent_ready() first = ui.selected_tab await ui.write_draft(script({"ask": "PERMISSION_KEPT"})) diff --git a/tests/app/test_prompt_routing.py b/tests/app/test_prompt_routing.py index 0f4b53247..66920ddef 100644 --- a/tests/app/test_prompt_routing.py +++ b/tests/app/test_prompt_routing.py @@ -1,10 +1,11 @@ """Typed text goes to the agent as written; only shell mode runs a command.""" +from toad.session_scope import AgentAttached from support.agents import peer async def test_command_looking_text_is_sent_to_the_agent_not_the_shell(workspace): - async with workspace.app(agent=peer("echo")) as ui: + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() sent = ["openhcs", "open the project", "git status", "python", "$100 budget", "@peer", "!literal"] for text in sent: @@ -17,7 +18,7 @@ async def test_command_looking_text_is_sent_to_the_agent_not_the_shell(workspace async def test_enter_sends_the_whole_multiline_draft_and_ctrl_j_adds_a_line(workspace): - async with workspace.app(agent=peer("echo")) as ui: + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() for text in ("first line\nsecond line", "```python\nprint('hello')\n```"): await ui.write_draft(text) @@ -34,7 +35,7 @@ async def test_enter_sends_the_whole_multiline_draft_and_ctrl_j_adds_a_line(work async def test_an_exclamation_mark_is_text_not_a_shell_switch(workspace): - async with workspace.app(agent=peer("echo")) as ui: + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() await ui.press("!") assert ui.draft() == "!" diff --git a/tests/app/test_session_scope.py b/tests/app/test_session_scope.py index 4d99993dd..cb2a86bef 100644 --- a/tests/app/test_session_scope.py +++ b/tests/app/test_session_scope.py @@ -5,7 +5,7 @@ from agent_comms.launch_root import LaunchRoot from support.agents import peer -from toad.session_scope import NoSessionOrigin, NoTranscript, SessionScope +from toad.session_scope import AgentAttached, NoSessionOrigin, NoTranscript, SessionScope async def test_real_conversation_discovers_screen_free_scope(workspace): @@ -15,7 +15,7 @@ async def test_real_conversation_discovers_screen_free_scope(workspace): unavailable.resolve_thread() LaunchRoot.from_path(workspace.roots.wire).install() definition = peer("echo") - async with workspace.app(agent=definition) as ui: + async with workspace.app(binding=AgentAttached(definition)) as ui: app, _pilot = ui.textual first = ui.selected_tab view = app.selected_session diff --git a/tests/app/test_submissions_in_a_row.py b/tests/app/test_submissions_in_a_row.py index a2d73298a..1462b5b47 100644 --- a/tests/app/test_submissions_in_a_row.py +++ b/tests/app/test_submissions_in_a_row.py @@ -1,10 +1,11 @@ """Twenty submissions in a row and then typed keys all reach the agent, in order and none lost.""" +from toad.session_scope import AgentAttached from support.agents import peer async def test_consecutive_submissions_and_keys_typed_after_them_reach_the_agent_in_order(workspace): - async with workspace.app(agent=peer("echo")) as ui: + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() messages = [f"message {index}" for index in range(20)] for index, text in enumerate(messages): diff --git a/tests/app/test_welcome_view.py b/tests/app/test_welcome_view.py index 5cbdff99b..025d74301 100644 --- a/tests/app/test_welcome_view.py +++ b/tests/app/test_welcome_view.py @@ -1,4 +1,5 @@ """Toad starts with no tab open and shows the welcome view until one is opened.""" +from toad.session_scope import AgentAttached from support.agents import peer @@ -18,7 +19,7 @@ async def focus_row(ui, kind: str) -> None: async def test_starting_opens_no_tab_and_starts_no_agent(workspace): workspace.thread("alpha", "team") - async with workspace.app(session=False, agent=peer("echo")) as ui: + async with workspace.app(session=False, binding=AgentAttached(peer("echo"))) as ui: await ui.until_shows(THREADS) assert ui.tabs() == () app, _ = ui.textual @@ -65,7 +66,7 @@ async def test_opening_a_thread_run_elsewhere_opens_its_history_read_as_the_user async def test_closing_the_last_tab_shows_the_view_again_and_starts_no_agent(workspace): - async with workspace.app(session=False, agent=peer("echo")) as ui: + async with workspace.app(session=False, binding=AgentAttached(peer("echo"))) as ui: await ui.until_shows(HINT) await ui.press("n") await ui.until(lambda: len(ui.tabs()) == 1, why="the new session's tab") diff --git a/tests/native/test_native_catalog_launch.py b/tests/native/test_native_catalog_launch.py index 5f8342487..23b4dc621 100644 --- a/tests/native/test_native_catalog_launch.py +++ b/tests/native/test_native_catalog_launch.py @@ -8,6 +8,7 @@ from toad.screens.agent_modal import AgentModal from toad.screens.store import AgentItem, LauncherGridSelect, LauncherItem, StoreScreen from toad.preferences import SETTINGS +from toad.session_scope import AgentAttached async def test_store_launch_starts_a_session_and_leaves_the_open_one_alone(workspace, native_package): @@ -15,7 +16,7 @@ async def test_store_launch_starts_a_session_and_leaves_the_open_one_alone(works from toad.app import ToadApp def with_catalog_agent(**arguments): - return ToadApp(**{**arguments, "agent_data": definition}) + return ToadApp(**{**arguments, "agent_binding": AgentAttached(definition)}) async with native_session(workspace, native_package, application=with_catalog_agent) as native: ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider diff --git a/tests/native/test_thread_open_during_slow_load.py b/tests/native/test_thread_open_during_slow_load.py index 49bb5c30c..b31aa925b 100644 --- a/tests/native/test_thread_open_during_slow_load.py +++ b/tests/native/test_thread_open_during_slow_load.py @@ -1,5 +1,6 @@ """Opening a thread whose session loads slowly: one tab from the first click, the loading state, then the saved history; the session left behind keeps its editor.""" +from toad.session_scope import AgentAttached import asyncio from functools import partial @@ -41,7 +42,7 @@ def recording_draw(screen, renderable): app._display = recording_draw return app - async with workspace.app(size=(139, 25), agent=peer("delayed_load"), application=started) as ui: + async with workspace.app(size=(139, 25), binding=AgentAttached(peer("delayed_load")), application=started) as ui: app, pilot = ui.textual await ui.until_agent_ready() source = app.selected_session diff --git a/tests/support/core_adapters/human_input_observer.py b/tests/support/core_adapters/human_input_observer.py index 29b7b010a..0aa55854b 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -23,6 +23,7 @@ from agent_comms.retained_task_facts import HumanInputTaskFact from toad.agent_schema import AgentDefinition from toad.app import ToadApp +from toad.session_scope import AgentAttached async def until(pilot, predicate, seconds=20): @@ -175,7 +176,7 @@ def phase(name): 'short_name': 's2-s5', 'protocol': 'acp', 'run_command': {'*': shlex.join([sys.executable, '-m', 'agent_comms.acp'])}, }) - app = ToadApp(agent_data=definition, project_dir=subject.worktree, + app = ToadApp(agent_binding=AgentAttached(definition), project_dir=subject.worktree, agent_session_id=subject.name) phase('before_actual_toad_run_test') async with app.run_test(headless=True, size=(160, 44)) as pilot: diff --git a/tests/support/failed_close_app.py b/tests/support/failed_close_app.py index 4b36465ab..8c03960d2 100644 --- a/tests/support/failed_close_app.py +++ b/tests/support/failed_close_app.py @@ -7,6 +7,7 @@ import sys from toad.app import ToadApp +from toad.session_scope import AgentAttached from toad.cli import run_terminal from toad.widgets.conversation import Conversation @@ -39,4 +40,4 @@ def break_release(self): if __name__ == "__main__": - run_terminal(FailedCloseApp(project_dir=sys.argv[1], agent_data=peer("echo"))) + run_terminal(FailedCloseApp(project_dir=sys.argv[1], agent_binding=AgentAttached(peer("echo")))) diff --git a/tests/support/first_frame_app.py b/tests/support/first_frame_app.py index 62a199f04..2bf6615b1 100644 --- a/tests/support/first_frame_app.py +++ b/tests/support/first_frame_app.py @@ -12,6 +12,7 @@ from textual.widgets import Input from toad.agent_schema import AgentDefinition from toad.app import ToadApp +from toad.session_scope import AgentAttached from toad.screens.workspace import WorkspaceScreen from toad.widgets.prompt import Prompt from toad.widgets.slash_complete import SlashComplete @@ -52,9 +53,9 @@ def record_frame(self): if __name__ == "__main__": peer = Path(__file__).with_name("peers") / "first_frame.py" - app = FrameApp(project_dir=sys.argv[1], agent_data=AgentDefinition.decode({ + app = FrameApp(project_dir=sys.argv[1], agent_binding=AgentAttached(AgentDefinition.decode({ "name": "Physical frame peer", "identity": "frame-acceptance", "short_name": "Frame", "protocol": "acp", "run_command": {"*": shlex.join([sys.executable, str(peer)])}, - })) + }))) app.run() diff --git a/tests/support/native.py b/tests/support/native.py index a47e07ed9..88ba89e10 100644 --- a/tests/support/native.py +++ b/tests/support/native.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +from toad.session_scope import AgentAttached import asyncio from collections.abc import Callable @@ -244,7 +245,7 @@ async def attached_app(workspace, comms, thread: str, *, size: tuple[int, int] = if project is not None: started = partial(started, project_dir=str(project)) try: - async with workspace.app(size=size, agent=comms_agent(), application=_first_wins(started)) as ui: + async with workspace.app(size=size, binding=AgentAttached(comms_agent()), application=_first_wins(started)) as ui: await ui.until(lambda: ui.conversation.agent is not None, seconds=20, why="the agent object") agent = ui.conversation.agent await ui.until(agent.session.settled.is_set, seconds=30, why="the ACP session to settle") diff --git a/tests/support/observed_app.py b/tests/support/observed_app.py index 849b5f365..8c7efcd43 100644 --- a/tests/support/observed_app.py +++ b/tests/support/observed_app.py @@ -7,6 +7,7 @@ import sys from toad.app import ToadApp +from toad.session_scope import AgentAttached from toad.cli import run_terminal from agents import peer @@ -33,4 +34,4 @@ def note_observer(self): if __name__ == "__main__": - run_terminal(ObservedApp(project_dir=sys.argv[1], agent_data=peer("echo"))) + run_terminal(ObservedApp(project_dir=sys.argv[1], agent_binding=AgentAttached(peer("echo")))) diff --git a/tests/support/ui.py b/tests/support/ui.py index b7db73747..1e7aaaf3c 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -16,7 +16,6 @@ from support.agents import AppAgents from toad.navigation_context import NavigationContext -from toad.session_scope import NoAgent if TYPE_CHECKING: from textual.pilot import Pilot @@ -319,7 +318,7 @@ async def new_session(self) -> str: app = self.app details = await app.session_navigation.new( - lambda: MainScreen(app.project_dir, NoAgent().attached(app.agent_data)) + lambda: MainScreen(app.project_dir, app.agent_binding) ) await self.step() return details.mode_name diff --git a/tests/support/workspace.py b/tests/support/workspace.py index ec4ed9feb..581636ac6 100644 --- a/tests/support/workspace.py +++ b/tests/support/workspace.py @@ -10,10 +10,10 @@ from support.agents import AppAgents from support.roots import Roots from support.ui import TextualUi +from toad.session_scope import AgentBinding, NoAgent if TYPE_CHECKING: from agent_comms.comms import Comms - from toad.agent_schema import AgentDefinition class Workspace: @@ -85,7 +85,7 @@ def agents(self) -> AppAgents: return AppAgents.unrun(self.project) @asynccontextmanager - async def app(self, *, size: tuple[int, int] = (120, 40), agent: AgentDefinition | None = None, + async def app(self, *, size: tuple[int, int] = (120, 40), binding: AgentBinding = NoAgent(), application: type | None = None, session: bool = True, resume: str | None = None): """Start Toad headless on this project; yields once its first session is shown. @@ -95,7 +95,7 @@ async def app(self, *, size: tuple[int, int] = (120, 40), agent: AgentDefinition from toad.app import ToadApp launch = {} if resume is None else {"agent_session_id": resume} - app = (application or ToadApp)(agent_data=agent, project_dir=str(self.project), **launch) + app = (application or ToadApp)(agent_binding=binding, project_dir=str(self.project), **launch) async with app.run_test(size=size) as pilot: ui = TextualUi(app, pilot) if session: diff --git a/tests/textual/test_acp_client_requests.py b/tests/textual/test_acp_client_requests.py index 0ec7c26c8..d8f24194c 100644 --- a/tests/textual/test_acp_client_requests.py +++ b/tests/textual/test_acp_client_requests.py @@ -1,5 +1,6 @@ """Textual-facing: a scripted ACP agent uses every client request (modes, files, permission, terminals); the permission dialog and the tool call block are the Textual widgets that answer and show them.""" +from toad.session_scope import AgentAttached from support.agents import peer from toad.core import input_events @@ -8,7 +9,7 @@ async def test_agent_requests_are_served_and_the_reply_is_drawn(workspace): - async with workspace.app(size=(130, 44), agent=peer("specification")) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(peer("specification"))) as ui: app, _pilot = ui.textual await ui.until_agent_ready() view = ui.conversation diff --git a/tests/textual/test_acp_log_link.py b/tests/textual/test_acp_log_link.py index 2cc8731c7..c16d9ec20 100644 --- a/tests/textual/test_acp_log_link.py +++ b/tests/textual/test_acp_log_link.py @@ -1,5 +1,6 @@ """Textual-facing: the "Open ACP log" link under a failed request is a Textual link target drawn with the widget's hover link style, and the tab it opens holds the log in the log view's text area.""" +from toad.session_scope import AgentAttached import asyncio @@ -23,7 +24,7 @@ async def shown_log(ui): async def test_the_log_link_highlights_under_the_pointer_and_opens_the_agents_log_in_one_tab(workspace): - async with workspace.app(size=(139, 30), agent=peer("refuses_initialize")) as ui: + async with workspace.app(size=(139, 30), binding=AgentAttached(peer("refuses_initialize"))) as ui: app, pilot = ui.textual await ui.until_shows("AGENT_REFUSED_TO_START", seconds=20) await ui.until(lambda: ui.shows(LINK) and ui.link_at(LINK) is not None, why="the log link drawn as a link") diff --git a/tests/textual/test_application_actions_in_the_running_app.py b/tests/textual/test_application_actions_in_the_running_app.py index 6c42d0ec8..c877aedd3 100644 --- a/tests/textual/test_application_actions_in_the_running_app.py +++ b/tests/textual/test_application_actions_in_the_running_app.py @@ -1,4 +1,5 @@ """Textual-facing: application actions (help, settings screen, command palette, sidebar buttons, tab close) run through Textual screens and keep the session, its draft and the saved settings.""" +from toad.session_scope import AgentAttached from contextlib import asynccontextmanager import json @@ -39,7 +40,7 @@ def save_settings(workspace, **ui): async def running_session(workspace): """The app on saved settings, its completion agent ready and one reply received.""" config, path = save_settings(workspace) - async with workspace.app(size=(130, 44), agent=peer("completion", name="Action peer", identity="action-peer")) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(peer("completion", name="Action peer", identity="action-peer"))) as ui: await ui.until_agent_ready() await ui.type('FIRST_REPLY') await ui.press('enter') @@ -151,7 +152,7 @@ async def test_quitting_from_a_focused_editor_saves_it_and_the_reopened_app_repl saved = json.loads(path.read_text()) assert saved['ui']['column-width'] == 137, saved assert saved['anon_id'] == 'durable-id' - async with workspace.app(size=(130, 44), agent=peer("completion", name="Action peer", identity="action-peer")) as reopened: + async with workspace.app(size=(130, 44), binding=AgentAttached(peer("completion", name="Action peer", identity="action-peer"))) as reopened: await reopened.until_agent_ready() app = reopened.textual[0] assert app.settings.ui.column_width == 137 and app.settings.ui.footer is True diff --git a/tests/textual/test_local_echo.py b/tests/textual/test_local_echo.py index 8ad5717c6..482dc562e 100644 --- a/tests/textual/test_local_echo.py +++ b/tests/textual/test_local_echo.py @@ -1,4 +1,5 @@ """Textual-facing: the conversation widget draws a submitted message before its slow history write finishes.""" +from toad.session_scope import AgentAttached import asyncio @@ -7,7 +8,7 @@ async def test_submission_is_drawn_before_slow_history_persistence_finishes(workspace): - async with workspace.app(agent=peer("echo")) as ui: + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() conversation = ui.conversation gate = asyncio.Event() diff --git a/tests/textual/test_plan_display.py b/tests/textual/test_plan_display.py index b947f3a35..8fa3ad87c 100644 --- a/tests/textual/test_plan_display.py +++ b/tests/textual/test_plan_display.py @@ -1,4 +1,5 @@ """Textual-facing: a plan sent by an ACP agent is drawn as one Plan widget in the conversation and in the sidebar (status markers, completion strike-through animation, reset, return), and malformed plan updates are rejected with a visible note.""" +from toad.session_scope import AgentAttached import asyncio import json @@ -41,9 +42,9 @@ async def planning_session(workspace, *plans): """The app with the plan peer connected; the prompt of `plans` is sent but the peer waits at each step.""" async with workspace.app( size=(130, 44), - agent=peer( + binding=AgentAttached(peer( "plan", name="Local ACP plan acceptance", identity="plan-acceptance" - ), + )), ) as ui: view = ui.conversation await ui.until(lambda: view.agent is not None, why="the agent") diff --git a/tests/textual/test_prompt_completion.py b/tests/textual/test_prompt_completion.py index 7d5607d92..cbd6c5917 100644 --- a/tests/textual/test_prompt_completion.py +++ b/tests/textual/test_prompt_completion.py @@ -1,5 +1,6 @@ """Textual-facing: the prompt's popups (slash commands, file search, model picker), its cursor and selection in wrapped text, and the channel composer's mention list are Textual widgets that paint and take focus.""" +from toad.session_scope import AgentAttached import json @@ -31,7 +32,7 @@ async def ready_prompt(workspace, ui): async def test_slash_command_discovered_from_the_agent_is_completed_by_keyboard_and_executed(workspace): - async with workspace.app(size=(130, 44), agent=completion_peer()) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(completion_peer())) as ui: view, prompt, agent = await ready_prompt(workspace, ui) await agent.send_prompt("publish") await ui.until(lambda: any(command.command == "/proofcmd" for command in prompt.slash_commands), @@ -54,7 +55,7 @@ async def test_slash_command_discovered_from_the_agent_is_completed_by_keyboard_ async def test_up_and_down_move_through_wrapped_rows_before_they_move_through_history(workspace): - async with workspace.app(size=(130, 44), agent=completion_peer()) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(completion_peer())) as ui: view, prompt, agent = await ready_prompt(workspace, ui) area = prompt.prompt_text_area history = view.input_histories.prompt @@ -81,7 +82,7 @@ async def test_up_and_down_move_through_wrapped_rows_before_they_move_through_hi async def test_a_whole_command_is_selected_backwards_and_the_agents_hint_refreshes(workspace): - async with workspace.app(size=(130, 44), agent=completion_peer()) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(completion_peer())) as ui: view, prompt, agent = await ready_prompt(workspace, ui) await agent.send_prompt("publish") await ui.until(lambda: any(command.command == "/proofcmd" for command in prompt.slash_commands), @@ -104,7 +105,7 @@ async def test_a_whole_command_is_selected_backwards_and_the_agents_hint_refresh async def test_channel_composer_completes_a_mention_with_tab_and_keeps_focus(workspace): - async with workspace.app(size=(130, 44), agent=completion_peer()) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(completion_peer())) as ui: app, _pilot = ui.textual view, prompt, agent = await ready_prompt(workspace, ui) channel = ChannelPrompt(simple_input=True) @@ -137,7 +138,7 @@ async def test_file_search_inserts_a_path_and_follows_picker_focus_resize_and_ca for index in range(128): (workspace.project / "folder" / f"target-{index:03}.txt").write_text(str(index)) (workspace.project / "space name.txt").write_text("Space-bearing file") - async with workspace.app(size=(130, 44), agent=completion_peer()) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(completion_peer())) as ui: _app, pilot = ui.textual view, prompt, agent = await ready_prompt(workspace, ui) prompt.text = "Attach " @@ -187,7 +188,7 @@ async def test_file_search_inserts_a_path_and_follows_picker_focus_resize_and_ca async def test_model_picker_selects_through_the_agent_and_keeps_the_draft(workspace): - async with workspace.app(size=(130, 44), agent=completion_peer()) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(completion_peer())) as ui: _app, pilot = ui.textual view, prompt, agent = await ready_prompt(workspace, ui) paths, slash = prompt.path_search, prompt.slash_complete diff --git a/tests/textual/test_prompt_shell_mode.py b/tests/textual/test_prompt_shell_mode.py index 55eb949c3..53881b632 100644 --- a/tests/textual/test_prompt_shell_mode.py +++ b/tests/textual/test_prompt_shell_mode.py @@ -1,11 +1,12 @@ """Typing ! in an empty prompt enters shell mode for one submission.""" +from toad.session_scope import AgentAttached from support.agents import peer from toad.widgets.prompt import Prompt async def test_shell_mode_runs_one_command_then_returns_to_agent_input(workspace): - async with workspace.app(agent=peer("echo")) as ui: + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() app, _pilot = ui.textual prompt = app.selected_session.query_one(Prompt) diff --git a/tests/textual/test_quit_with_unsaved_settings.py b/tests/textual/test_quit_with_unsaved_settings.py index 9b32e5e63..4543300ab 100644 --- a/tests/textual/test_quit_with_unsaved_settings.py +++ b/tests/textual/test_quit_with_unsaved_settings.py @@ -1,4 +1,5 @@ """Textual-facing: quitting with a settings edit that cannot be written keeps the app open, shows the error and keeps the draft; a retry saves the exact edit and exits.""" +from toad.session_scope import AgentAttached import json @@ -14,7 +15,7 @@ async def test_quit_that_cannot_save_stays_open_and_a_retry_saves_the_edit_and_e path = config / 'toad.json' path.write_text(json.dumps({'anon_id': 'quit-save-durable', 'statistics': {'allow_collect': False}, 'ui': {'column-width': 100}})) - async with workspace.app(size=(130, 44), agent=peer("completion", name="Quit save peer", identity="quit-save-peer")) as ui: + async with workspace.app(size=(130, 44), binding=AgentAttached(peer("completion", name="Quit save peer", identity="quit-save-peer"))) as ui: app, pilot = ui.textual view = ui.conversation record_notifications(app) diff --git a/tests/textual/test_session_tool_calls.py b/tests/textual/test_session_tool_calls.py index 1e99c5738..f1ddf628b 100644 --- a/tests/textual/test_session_tool_calls.py +++ b/tests/textual/test_session_tool_calls.py @@ -1,4 +1,5 @@ """Textual-facing: agent tool-call updates reach the ToolCall widget as merged snapshots, and the widget paints the read output.""" +from toad.session_scope import AgentAttached import sys from pathlib import Path @@ -21,7 +22,7 @@ def profile(frame, event, arg): if frame.f_code is constructor and frame.f_back.f_code is SessionToolCalls.merge.__code__: counts["missing_start_constructions"] += 1 - async with workspace.app(size=(110, 35), agent=peer("echo")) as ui: + async with workspace.app(size=(110, 35), binding=AgentAttached(peer("echo"))) as ui: view = ui.conversation await ui.until(lambda: view.agent is not None, seconds=20, why="the agent to be created") agent = view.agent diff --git a/tests/textual/test_workspace_tabs_share_one_agent_connection.py b/tests/textual/test_workspace_tabs_share_one_agent_connection.py index ef5b6bddb..040b86b81 100644 --- a/tests/textual/test_workspace_tabs_share_one_agent_connection.py +++ b/tests/textual/test_workspace_tabs_share_one_agent_connection.py @@ -7,7 +7,7 @@ from toad.app import ToadApp from toad.screens.main import MainScreen -from toad.session_scope import AgentSession +from toad.session_scope import AgentAttached, AgentSession async def ready(agent): @@ -29,7 +29,7 @@ async def test_a_second_tab_reuses_the_first_tabs_agent_process_and_closing_it_k async with threads_owned_here(comms, project): application = partial(ToadApp, agent_session_id="alpha") async with workspace.app( - size=(110, 36), agent=definition, application=application + size=(110, 36), binding=AgentAttached(definition), application=application ) as ui: app, _pilot = ui.textual await ui.until( From a1e303883f239edca20fd9a64721eae0cc4fffde Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 12:30:06 -0400 Subject: [PATCH 24/43] Require the startup binding at every application entrypoint --- pyproject.toml | 2 +- src/toad/app.py | 5 ++--- src/toad/cli.py | 2 +- src/toad/screens/store.py | 4 +++- tests/support/agents.py | 4 +++- tests/support/core_adapters/mcp_permission_observer.py | 3 ++- tests/textual/test_store_agent_actions.py | 3 ++- uv.lock | 4 ++-- 8 files changed, 16 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ccf6944d0..d057e7047 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "7a423460636f72a9e910d1b0a7d42fcf92d6c5b1" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "e7f2a25c8078ebd453cce2ce81e2f7b9186bc558" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", branch = "main" } diff --git a/src/toad/app.py b/src/toad/app.py index 760f8c50e..c221fbaf3 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -34,7 +34,7 @@ from toad.core import session_requests from toad.core_event_carrier import CoreEventReceiver from agent_comms.mro_dispatch import handles -from toad.session_scope import AgentBinding, NoAgent +from toad.session_scope import AgentBinding from toad.render_backend import Renderer from toad.work_preparation import PreparationRuntime from toad.navigation_preparation import ( @@ -148,7 +148,7 @@ class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown def __init__( self, - agent_binding: AgentBinding = NoAgent(), + agent_binding: AgentBinding, project_dir: str | None = None, mode: str | None = None, agent_session_id: str | None = None, @@ -159,7 +159,6 @@ def __init__( agent_binding: The agent selected at startup. project_dir: Project directory. mode: Initial mode. - agent: Agent identity or shor name. """ from toad.render_processes import RenderProcessPool from toad.render_backend import PreparedRenderer diff --git a/src/toad/cli.py b/src/toad/cli.py index 005a340bd..a0bd120a4 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -284,7 +284,7 @@ def about() -> None: from toad import about from toad.app import ToadApp - app = ToadApp() + app = ToadApp(agent_binding=NoAgent()) print(about.render(app)) diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index 962f4c86c..8015c8cff 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -595,6 +595,8 @@ async def action_directory(self) -> None: if __name__ == "__main__": from toad.app import ToadApp - app = ToadApp(mode="store") + from toad.session_scope import NoAgent + + app = ToadApp(agent_binding=NoAgent(), mode="store") app.run() diff --git a/tests/support/agents.py b/tests/support/agents.py index 991a7af36..c9776fb4b 100644 --- a/tests/support/agents.py +++ b/tests/support/agents.py @@ -7,6 +7,8 @@ import sys from typing import TYPE_CHECKING +from toad.session_scope import NoAgent + if TYPE_CHECKING: from toad.acp.agent import Agent from toad.agent_schema import AgentDefinition @@ -46,7 +48,7 @@ def unrun(cls, project: Path) -> AppAgents: """For a test with no app running: agents of an app built on ``project`` and never run.""" from toad.app import ToadApp - return cls(ToadApp(project_dir=str(project))) + return cls(ToadApp(agent_binding=NoAgent(), project_dir=str(project))) def agent[A: Agent](self, kind: type[A], definition: AgentDefinition, session_id: str | None = None, **fields: object) -> A: diff --git a/tests/support/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index a3defddd7..9133f816d 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -144,6 +144,7 @@ async def disconnected(self): @asynccontextmanager async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer]: from toad.app import ToadApp + from toad.session_scope import NoAgent root = Path(artifact_dir).resolve() root.mkdir(parents=True, exist_ok=True) @@ -153,7 +154,7 @@ async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer previous = {key: os.environ.get(key) for key in env} os.environ.update(env) try: - app = ToadApp(project_dir=str(root / "project")) + app = ToadApp(agent_binding=NoAgent(), project_dir=str(root / "project")) async with app.run_test(size=(120, 40)) as pilot: # Toad starts with no tab open; the observed session is opened here. await app.session_navigation.new(app.session_navigation.default_source) diff --git a/tests/textual/test_store_agent_actions.py b/tests/textual/test_store_agent_actions.py index a6e4486c5..ce00a1d50 100644 --- a/tests/textual/test_store_agent_actions.py +++ b/tests/textual/test_store_agent_actions.py @@ -11,6 +11,7 @@ from support.catalog_steps import choose_action, open_agent, run_action from toad.agent_schema import ChatAgentKind from toad.app import ToadApp +from toad.session_scope import NoAgent from toad.catalog_actions import CatalogCommandAction from toad.screens.command_edit_modal import CommandEditModal from toad.screens.store import AgentItem, StoreScreen @@ -35,7 +36,7 @@ async def test_agent_actions_run_edit_fail_cancel_and_take_input(workspace): "anon_id": "00000000-0000-0000-0000-000000000001", "statistics": {"allow_collect": False}})) with private_catalog_entry(): - app = ToadApp(mode="store", project_dir=str(workspace.project)) # starts on the Store: no session to wait for + app = ToadApp(agent_binding=NoAgent(), mode="store", project_dir=str(workspace.project)) # starts on the Store: no session to wait for async with app.run_test(size=(140, 48)) as pilot: ui = TextualUi(app, pilot) await ui.until(lambda: isinstance(app.screen, StoreScreen), why="the Store") diff --git a/uv.lock b/uv.lock index e6d010305..09eed848f 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=7a423460636f72a9e910d1b0a7d42fcf92d6c5b1#7a423460636f72a9e910d1b0a7d42fcf92d6c5b1" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=e7f2a25c8078ebd453cce2ce81e2f7b9186bc558#e7f2a25c8078ebd453cce2ce81e2f7b9186bc558" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, @@ -302,7 +302,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=7a423460636f72a9e910d1b0a7d42fcf92d6c5b1" }, + { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=e7f2a25c8078ebd453cce2ce81e2f7b9186bc558" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, From dddccab8c4c776127c84434156a7ed988a697425 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:14:07 -0400 Subject: [PATCH 25/43] Use the existing leaf projection for reference-backed tab labels --- src/toad/session_entry.py | 5 +++-- src/toad/session_presentation.py | 3 +-- src/toad/widgets/comms_chat.py | 3 ++- src/toad/widgets/session_details.py | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index 8877a54c6..c3befa0cf 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -13,11 +13,12 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.display_references import CurrentDisplayReferences, ReferenceContext -from agent_comms.mro_dispatch import MroDispatch, handles +from agent_comms.mro_dispatch import handles from agent_comms.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.sidebar import ThreadRowModel +from toad.core.projection import MroProjection from toad.owner_preparation import ExistingOwnerPlan from toad.session_scope import SessionScope from toad.session_tracker import ( @@ -215,7 +216,7 @@ def forget(self, sessions: SessionNavigation) -> None: sessions.app.session_tracker.close_session(self.mode) -class HistorySessionEntry(SessionEntry, MroDispatch): +class HistorySessionEntry(SessionEntry, MroProjection): def __init__( self, mode: str, diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 0928f7dcd..72209b1dd 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -22,6 +22,7 @@ from toad.resource_release import AsyncResourceRelease from toad.widgets.conversation import Conversation from toad.widgets.history_anchor import ParkedPosition +from toad.widgets.prompt import MessagePrompt if TYPE_CHECKING: from toad.app import ToadApp @@ -243,8 +244,6 @@ def agent_info(self, conversation: Conversation) -> str: return conversation.thread_activity.row.presentation.title def prompt(self, conversation: Conversation): - from toad.widgets.prompt import MessagePrompt - return MessagePrompt() def input_ready(self, conversation: Conversation) -> bool: diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 3b81aa2e1..6230533ca 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -219,9 +219,10 @@ def preparing_identity(self, references) -> None: self.agent_info = Content(self.conversation.initial_label) @handles(CurrentDisplayReferences) - def referenced_identity(self, references) -> None: + def referenced_identity(self, references) -> object: self.agent_info = Content(self.conversation.label(references)) self.refresh_thread_status() + return self.DISCARD def transcript_scope(self) -> NoTranscript: """DM/channel history belongs to WireHistory, not a native transcript.""" diff --git a/src/toad/widgets/session_details.py b/src/toad/widgets/session_details.py index 7d04a4b02..e65d6656b 100644 --- a/src/toad/widgets/session_details.py +++ b/src/toad/widgets/session_details.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from agent_comms.mro_dispatch import handles +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.thread_presentation import ( ThreadPresentationStatus, SessionDisconnected, WorkingThreadPresentationStatus, ThreadRetired, @@ -157,7 +158,7 @@ class ConversationSessionDetails(SessionDetails): DEFAULT_ID = None - def __init__(self, peer) -> None: + def __init__(self, peer: ThreadIncarnation) -> None: self.peer = peer super().__init__(history=NativeHistory(), delivery=InputDeliveryBar()) From 550f9e56b86f7ed41bfb809a98e8fe2c0712af16 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:28:53 -0400 Subject: [PATCH 26/43] Cover visible DM replacement and incarnation-specific view retirement --- src/toad/navigation_target.py | 1 + src/toad/status_thread.py | 5 ++- src/toad/widgets/channel_activity.py | 4 +- src/toad/widgets/comms_chat.py | 2 +- tests/textual/test_conversation_value.py | 52 ++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 305336643..12e29524f 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -28,6 +28,7 @@ if TYPE_CHECKING: from agent_comms.display_references import CurrentDisplayReferences + from toad.navigation_context import NavigationContext, NavigationOwner diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py index ed8ecd4f2..01c7e2fd6 100644 --- a/src/toad/status_thread.py +++ b/src/toad/status_thread.py @@ -14,7 +14,10 @@ from toad.core_event_carrier import ThreadCallbackSubscription if TYPE_CHECKING: - from toad.widgets.observed_thread_activity import ObservedThreadActivity, ThreadStatusOwner + from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, + ) class StatusThread(ABC): diff --git a/src/toad/widgets/channel_activity.py b/src/toad/widgets/channel_activity.py index 778d1ffc1..2ce2690eb 100644 --- a/src/toad/widgets/channel_activity.py +++ b/src/toad/widgets/channel_activity.py @@ -1,13 +1,13 @@ """Live channel members use the sidebar's keyed widget reconciliation.""" from textual import widgets -from textual.content import Content from textual.containers import VerticalGroup +from textual.content import Content from toad.session_scope import SessionScope from toad.status_thread import StatusThread -from toad.widgets.sidebar_tree import KeyedRows, SidebarChange, SidebarModelView from toad.widgets.session_details import ConversationSessionDetails +from toad.widgets.sidebar_tree import KeyedRows, SidebarChange, SidebarModelView class ChannelActivity(SidebarModelView, KeyedRows, VerticalGroup): diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 6230533ca..a223033a2 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -7,8 +7,8 @@ from pathlib import Path from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision -from agent_comms.field_codec import FieldCodec from agent_comms.display_references import CurrentDisplayReferences, ReferenceContext +from agent_comms.field_codec import FieldCodec from agent_comms.messages import Message as WireMessage from agent_comms.mro_dispatch import handles from textual import containers diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 07d4563d4..8f8077a1c 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -140,3 +140,55 @@ async def test_renamed_dm_reopens_the_same_tab_and_sends_to_the_current_name(wor sent = [fragment.message for fragment in chat.message_history.fragments_in_range if fragment.message.body == "AFTER_RENAME"] assert len(sent) == 1 assert sent[0].target == "renamed" + + +async def test_recreated_dm_peer_retires_the_old_view_and_opens_a_new_incarnation(workspace): + peer = workspace.thread("peer", "team") + viewer = workspace.viewer + async with workspace.app(size=(110, 35)) as ui: + app, _ = ui.textual + owner = ui.selected_tab + context = NavigationContext(app, owner, workspace.project, viewer) + old_mode = await DirectTarget("peer").open(context) + old_screen = app.selected_session + await old_screen.wait_content_ready() + old_chat = old_screen.query_one(CommsChatView) + old_window = old_chat.window + old_chat.prompt.text = "belongs only to the original peer" + + def replace_peer(): + registry = workspace.comms.registry + registry.unregister(peer.name) + registry.remove_originals((registry.require(peer.name),)) + return registry.declare(registry.new_thread( + peer.name, peer.tags, peer.worktree, created_at=peer.created_at + 1, + )) + + successor = await asyncio.to_thread(replace_peer) + await ui.until( + lambda: app.session_navigation.get(old_mode) is None, + why="Core retiring the deleted peer's visible DM view", + ) + await ui.select_tab(owner) + new_mode = await DirectTarget("peer").open(context) + screen = app.selected_session + await screen.wait_content_ready() + chat = screen.query_one(CommsChatView) + entry = app.session_navigation.get(new_mode) + assert new_mode != old_mode + assert screen is not old_screen + assert chat is not old_chat + assert chat.window is not old_window + assert entry.key.conversation.peer == successor.incarnation + assert entry.key.conversation.peer != peer.incarnation + assert chat.prompt.text == "" + await ui.write_draft("FOR_THE_SUCCESSOR") + await ui.press("enter") + await ui.until( + lambda: "FOR_THE_SUCCESSOR" in ui.painted(chat.window), + why="a fresh message in the successor's distinct DM view", + ) + sent = [fragment.message for fragment in chat.message_history.fragments_in_range + if fragment.message.body == "FOR_THE_SUCCESSOR"] + assert len(sent) == 1 + assert sent[0].target == successor.name From 8ddf7afa74796b36cc359d39bdbec992ce24d609 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:49:09 -0400 Subject: [PATCH 27/43] Pair session ownership with the constructor-restored Core snapshot --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6fccffc5..4144e4d6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "c81fd44adb1e210bfb56dbd4da50e54ea54350e4" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "5e8b01627925a75c6c18899a2783ea38b21fb70e" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", branch = "main" } diff --git a/uv.lock b/uv.lock index 515faaf5a..9b068aff7 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=c81fd44adb1e210bfb56dbd4da50e54ea54350e4#c81fd44adb1e210bfb56dbd4da50e54ea54350e4" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=5e8b01627925a75c6c18899a2783ea38b21fb70e#5e8b01627925a75c6c18899a2783ea38b21fb70e" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, @@ -302,7 +302,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=c81fd44adb1e210bfb56dbd4da50e54ea54350e4" }, + { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=5e8b01627925a75c6c18899a2783ea38b21fb70e" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, From 72effea90a588c26c7f5c5cae53508ec9da9433f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:49:51 -0400 Subject: [PATCH 28/43] Declare ACP route errors at their leaf import boundary --- src/toad/acp/comms_updates.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 6fa06de30..68159bdc2 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from typing import Any from agent_comms.acp_extension import ( @@ -25,6 +26,7 @@ TurnChangedUpdate, ) from agent_comms.context_reading import UnmeasuredContext +from agent_comms.errors import RouteChangedError from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation @@ -132,10 +134,6 @@ def input_started(self, update: InputStartedUpdate) -> None: @handles(CoordinationChangedUpdate) def coordination_changed(self, update: CoordinationChangedUpdate) -> None: - from pathlib import Path - - from agent_comms.errors import RouteChangedError - agent = self.agent root = agent.controller.transcripts.access.launch_root.path if Path(update.wire_root).resolve() != root.resolve(): From 726c662c3f721c0bd066bb16ce52010c6de8f19b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:41:04 -0400 Subject: [PATCH 29/43] Retain activity rows by incarnation and publish current reference labels --- pyproject.toml | 2 +- src/toad/widgets/channel_activity.py | 33 +++++++++++++------- src/toad/widgets/observed_thread_activity.py | 7 +++-- src/toad/widgets/session_tabs.py | 3 +- src/toad/widgets/sidebar_tree.py | 16 +++++----- tests/textual/test_conversation_kind.py | 1 + tests/textual/test_conversation_value.py | 15 +++++++-- uv.lock | 4 +-- 8 files changed, 50 insertions(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4144e4d6c..b2b4e95d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "5e8b01627925a75c6c18899a2783ea38b21fb70e" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", branch = "main" } diff --git a/src/toad/widgets/channel_activity.py b/src/toad/widgets/channel_activity.py index 2ce2690eb..6712cb26e 100644 --- a/src/toad/widgets/channel_activity.py +++ b/src/toad/widgets/channel_activity.py @@ -1,5 +1,6 @@ """Live channel members use the sidebar's keyed widget reconciliation.""" +from agent_comms.thread_identity import ThreadIncarnation from textual import widgets from textual.containers import VerticalGroup from textual.content import Content @@ -10,7 +11,7 @@ from toad.widgets.sidebar_tree import KeyedRows, SidebarChange, SidebarModelView -class ChannelActivity(SidebarModelView, KeyedRows, VerticalGroup): +class ChannelActivity(SidebarModelView, KeyedRows[ThreadIncarnation], VerticalGroup): def __init__(self, view): self.view = view self._members = VerticalGroup() @@ -32,7 +33,7 @@ def member_container(self): @property def rows(self): return { - row.target.name: row + row.peer: row for row in self.member_container.children if not row.is_leaving } @@ -43,7 +44,7 @@ def members(self): @property def member_keys(self): - return tuple(row.name for row in self.members) + return tuple(row.incarnation for row in self.members) def accepts_observation(self) -> bool: return self.view.query_ancestor( @@ -56,19 +57,27 @@ def model_replaced(self) -> None: def sync(self, *, change: SidebarChange, reorder: bool = False) -> None: change.apply_activity(self) - def create_row(self, name): - row = ConversationSessionDetails( - self.model_state.shown.threads.rows[name].incarnation - ) - row.thread = StatusThread.for_root( - self.model_state.root(), name, self.app.coordination_access + def create_row(self, peer: ThreadIncarnation): + return ConversationSessionDetails(peer) + + def update_row(self, peer: ThreadIncarnation, row) -> None: + references = self.model_state.shown.observation.require_snapshot().references + row.bind(StatusThread.for_root( + self.model_state.root(), references.thread(peer).name, + self.app.coordination_access, + )) + + def changed_keys(self, names: frozenset[str]) -> frozenset[ThreadIncarnation]: + return frozenset( + member.incarnation for member in self.members if member.name in names + ) | frozenset( + row.peer for row in self.rows.values() if row.row.name in names ) - return row - def sync_members(self, changed: frozenset[str]) -> None: + def sync_members(self, changed: frozenset[ThreadIncarnation]) -> None: if self.is_attached and not self.is_leaving: members = self.members names = ", ".join(row.name for row in members[:2]) remaining = f" +{len(members) - 2}" if len(members) > 2 else "" self.details.title = Content(f"{len(members)} active · {names}{remaining}") - self.reconcile_keys(tuple(row.name for row in members), changed=changed) + self.reconcile_keys(tuple(row.incarnation for row in members), changed=changed) diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index f4f5e2fee..6336aacb9 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -62,10 +62,11 @@ def on_mount(self) -> None: def bind(self, thread: StatusThread) -> None: if thread != self.thread: - self.retire_core_observations(self.app.coordination_access.events) self.thread = thread - thread.observe(self) - thread.bind(self) + if self.is_mounted: + self.retire_core_observations(self.app.coordination_access.events) + thread.observe(self) + thread.bind(self) def status_changed(self, event: ThreadRowsChanged) -> None: self.thread.bind(self) diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 783ab49e7..fd46bd3a7 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -22,7 +22,6 @@ from textual._activation import PrimaryActivation from textual.widget import Widget -from toad import messages from agent_comms.mro_dispatch import handles from toad.core.events import SessionChangedEvent from toad.core_event_carrier import CoreEventReceiver @@ -286,7 +285,7 @@ async def _row_state_changed(self, event: CoreEventMessage) -> None: if geometry_changed: self.call_after_refresh(self.show_selected_tab) - @handles(core_events.OpenTabsChanged) + @handles(core_events.OpenTabsChanged, core_events.LinkContextChanged) async def _tabs_changed(self, event: CoreEventMessage) -> None: """A thread tab is a thread row: it repaints when an open session's state changes too.""" if self.screen.is_active: diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 2c5e70df3..221d81727 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -156,19 +156,19 @@ def take(self, key, destination): return row -class KeyedRows: +class KeyedRows[KeyT]: """Retain native member widgets by model key; the container owns their order.""" - def keep_row(self, key: str) -> bool: + def keep_row(self, key: KeyT) -> bool: return False - def should_mount(self, key: str) -> bool: + def should_mount(self, key: KeyT) -> bool: return True - def create_row(self, key: str): + def create_row(self, key: KeyT): raise NotImplementedError - def update_row(self, key: str, row) -> None: + def update_row(self, key: KeyT, row) -> None: pass def retire_row(self, row) -> None: @@ -180,7 +180,7 @@ def rows_changed(self) -> None: def place_row(self, row, index: int) -> None: """Publish list-position facts only for a new or moved row.""" - def reconcile_keys(self, keys, *, changed: frozenset[str]): + def reconcile_keys(self, keys, *, changed: frozenset[KeyT]): rows = self.rows retained = set(rows.values()) previous = { @@ -224,7 +224,7 @@ def reconcile_keys(self, keys, *, changed: frozenset[str]): return ordered -class SidebarGroup[RowT: "CommsRow"](KeyedRows, VerticalGroup): +class SidebarGroup[RowT: "CommsRow"](KeyedRows[str], VerticalGroup): """A common header/disclosure and optional bounded member viewport.""" navigation_header = False @@ -495,7 +495,7 @@ def apply_relationships(self, groups) -> None: group.sync_members(changed=changed) def apply_activity(self, view) -> None: - changed = self.names.intersection((*view.member_keys, *view.rows)) + changed = view.changed_keys(self.names) if changed: view.call_next(view.sync_members, changed) diff --git a/tests/textual/test_conversation_kind.py b/tests/textual/test_conversation_kind.py index 008dcf1c4..fa7d23c49 100644 --- a/tests/textual/test_conversation_kind.py +++ b/tests/textual/test_conversation_kind.py @@ -124,6 +124,7 @@ def test_dm_rename_retains_key_and_history_scope_but_resolves_current_name(copie def test_recreated_name_cannot_resolve_the_retired_dm(copied_conversations): comms, viewer = copied_conversations direct = DirectTarget("peer").prepare(comms, viewer).conversation + comms.registry.unregister("peer") comms.threads.delete("peer") comms.registry.declare(comms.registry.new_thread("peer", frozenset(), str(comms.root.parent))) recreated = DirectTarget("peer").prepare(comms, viewer).conversation diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 4bb5f764c..8edf7dff9 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -95,13 +95,21 @@ async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and await channel_target("#team").open(context) chat = app.selected_session.query_one(CommsChatView) - workspace.thread( + late_peer = workspace.thread( "late-peer", "team", running=True, title="Late conversation peer" ) + activity = chat.query_one(ChannelActivity) await ui.until( - lambda: "late-peer" in chat.query_one(ChannelActivity).rows, + lambda: late_peer.incarnation in activity.rows, why="the new member in the channel's activity rows", ) + member = activity.rows[late_peer.incarnation] + await asyncio.to_thread(workspace.comms.registry.rename, "late-peer", "renamed-late-peer") + await ui.until( + lambda: member.label == "renamed-late-peer", + why="the retained activity row following its peer's current name", + ) + assert activity.rows[late_peer.incarnation] is member async def test_renamed_dm_reopens_the_same_tab_and_sends_to_the_current_name(workspace): @@ -122,9 +130,10 @@ async def test_renamed_dm_reopens_the_same_tab_and_sends_to_the_current_name(wor chat.prompt.text = "draft survives rename" await asyncio.to_thread(workspace.comms.registry.rename, "peer", "renamed") await ui.until( - lambda: any(tab.identity == mode and tab.title == "@renamed" for tab in ui.tabs()), + lambda: any(tab.identity == mode and tab.title.startswith("@renamed") for tab in ui.tabs()), why="the retained DM tab following its incarnation's current name", ) + assert next(tab.title for tab in app.open_tabs if tab.mode_name == mode) == "@renamed" await ui.select_tab(owner) assert await DirectTarget("renamed").open(context) == mode assert app.session_navigation.get(mode) is entry diff --git a/uv.lock b/uv.lock index 9b068aff7..a45ae8477 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=5e8b01627925a75c6c18899a2783ea38b21fb70e#5e8b01627925a75c6c18899a2783ea38b21fb70e" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d#82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, @@ -302,7 +302,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=5e8b01627925a75c6c18899a2783ea38b21fb70e" }, + { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, From 8878cdc9de4edadbf71f30e06e315ee2b4270097 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:45:49 -0400 Subject: [PATCH 30/43] Check renamed-tab paint through the existing UI frame driver --- tests/textual/test_conversation_value.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 8edf7dff9..70c07e306 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -134,12 +134,20 @@ async def test_renamed_dm_reopens_the_same_tab_and_sends_to_the_current_name(wor why="the retained DM tab following its incarnation's current name", ) assert next(tab.title for tab in app.open_tabs if tab.mode_name == mode) == "@renamed" + prepared_pages = tuple(page.prepared for page in chat.message_history.pages) + fragments = chat.message_history.fragments await ui.select_tab(owner) - assert await DirectTarget("renamed").open(context) == mode + assert await ui.open_direct("renamed", actor=viewer, owner=owner) == mode assert app.session_navigation.get(mode) is entry assert app.selected_session is screen assert screen.query_one(CommsChatView) is chat assert chat.window is window + assert all(before is page.prepared for before, page in zip( + prepared_pages, chat.message_history.pages, strict=True, + )) + assert all(before is after for before, after in zip( + fragments, chat.message_history.fragments, strict=True, + )) assert entry.key.conversation.peer == peer.incarnation assert chat.prompt.text == "draft survives rename" assert "BEFORE_RENAME" in ui.painted(window) From d6a2c3b722f0bd8308d26e39bdad7186a2f22b60 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:57:34 -0400 Subject: [PATCH 31/43] Use the current Core goal execution family after main integration --- src/toad/widgets/conversation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 85573aba0..e89edad32 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable from acp import schema as acp_protocol -from agent_comms.goal_presentation import IdleGoalExecution, ThreadGoalExecution +from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution from agent_comms.goals import NoGoal from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation @@ -522,7 +522,7 @@ def mode_selection_available(self) -> bool: goal_display: var[GoalDisplay] = var(NoGoal()) - goal_execution: var[ThreadGoalExecution] = var(IdleGoalExecution()) + goal_execution: var[GoalExecution] = var(IdleGoalExecution()) title = var("") From a5bf65caa0b57f21be11a6f82569d86a52e92fdc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:01:07 -0400 Subject: [PATCH 32/43] Keep conversation attachment contracts on AgentConnection --- src/toad/widgets/conversation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index e89edad32..b4047568b 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -458,7 +458,7 @@ async def _thread_view_selected(self, event: CoreEventMessage) -> None: async def _thread_route_observed(self, event: CoreEventMessage) -> None: self.refresh_thread_status() - def bind_agent(self, agent: AgentBinding) -> None: + def bind_agent(self, agent: AgentConnection) -> None: self.agent_binding = agent agent.attach_view(self) @@ -761,7 +761,7 @@ async def on_agent_ready(self, message: CoreEventMessage) -> None: self.call_later(self.delivery_observation.invalidate) self.transcript.binding.request(self.transcript) - def watch_agent_binding(self, binding: AgentBinding) -> None: + def watch_agent_binding(self, binding: AgentConnection) -> None: # A presentation remount is not a fresh attachment. Start at the # Agent's current projection/floor so previously queued updates cannot # revive proof after its reducer entered quarantine or evidence loss. From 1fbde869a165b2c824c994e82503819ac6f8765d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:34:14 -0400 Subject: [PATCH 33/43] Read the bound agent in the slow thread-open journey --- tests/native/test_thread_open_during_slow_load.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/native/test_thread_open_during_slow_load.py b/tests/native/test_thread_open_during_slow_load.py index 3043c5fa2..44891d956 100644 --- a/tests/native/test_thread_open_during_slow_load.py +++ b/tests/native/test_thread_open_during_slow_load.py @@ -98,7 +98,7 @@ def held_read(request): tab = ui.selected_tab assert tab != owner view = app.selected_session - agent = view.conversation.agent + agent = view.conversation.agent_binding await ui.until(lambda: bool(view.query(ThreadLoading)), why="the loading block") await ui.until_shows(ThreadLoading.LABEL) assert tuple(app.tab_order.names) == (*tabs_before, tab) @@ -109,7 +109,7 @@ def held_read(request): assert await opening == tab and await duplicate == tab await ui.until(lambda: view.conversation.agent_ready, seconds=12, why="the opened thread to be ready") await ui.until_shows("SAVED_FIRST_OPEN_fresh-peer") - assert app.selected_session is view and view.conversation.agent is agent + assert app.selected_session is view and view.conversation.agent_binding is agent assert app.session_tracker.get_session(tab) is details await ui.until(lambda: not view.query(ThreadLoading), why="the loading block to go") # No message is sent here: this scripted agent claims a Core thread but publishes no input From 9bf099341f172e589591a2a04aee5a5e624b5ae8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:11:36 -0400 Subject: [PATCH 34/43] Pin the current P434 owner for paired app qualification --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b2b4e95d0..81576452f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "67d309df6311aec0d3d173290d941dfced7cf4f0" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", branch = "main" } diff --git a/uv.lock b/uv.lock index a45ae8477..7b4e7ed0b 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d#82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=67d309df6311aec0d3d173290d941dfced7cf4f0#67d309df6311aec0d3d173290d941dfced7cf4f0" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, @@ -302,7 +302,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=82d4947fe6890a58ebfdcdd7bf29d41c5ab7126d" }, + { name = "agent-comms", git = "https://github.com/OpenHCSDev/agent-comms.git?rev=67d309df6311aec0d3d173290d941dfced7cf4f0" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, From a32f8cf7bcf90530e16f4df14ab67e270725bef6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:43:25 -0400 Subject: [PATCH 35/43] Use declared route and send owners in the private UI fixtures --- .../test_thread_open_during_slow_load.py | 33 +++---- tests/support/routes.py | 15 +-- tests/textual/test_channel_participants.py | 5 +- .../test_channel_updates_and_returns.py | 6 +- tests/textual/test_default_route_flip.py | 2 +- .../textual/test_hidden_session_retirement.py | 2 +- .../test_historical_notification_source.py | 2 +- tests/textual/test_stale_route_writes.py | 92 ------------------- 8 files changed, 34 insertions(+), 123 deletions(-) delete mode 100644 tests/textual/test_stale_route_writes.py diff --git a/tests/native/test_thread_open_during_slow_load.py b/tests/native/test_thread_open_during_slow_load.py index 58c243dd5..26b6395f1 100644 --- a/tests/native/test_thread_open_during_slow_load.py +++ b/tests/native/test_thread_open_during_slow_load.py @@ -10,6 +10,7 @@ from support.agents import peer from support.native import start_private_native_wire +from support.stylesheets import INSTALLED from support.textual_steps import channel_roster from toad.app import ToadApp from toad.widgets.channels_sidebar import ChannelsSidebar @@ -17,6 +18,19 @@ from toad.widgets.conversation import ThreadLoading +class TabFramesApp(ToadApp): + CSS_PATH = INSTALLED + + def __init__(self, **arguments): + self.tab_sets_drawn = [] + super().__init__(**arguments) + + def _display(self, screen, renderable): + super()._display(screen, renderable) + if renderable is not None and not self._batch_count: + self.tab_sets_drawn.append(tuple(tab.mode_name for tab in self.open_tabs)) + + async def test_a_slow_thread_open_uses_one_tab_and_ends_with_saved_history(workspace, native_package): project = workspace.project comms = start_private_native_wire(workspace.roots.wire) @@ -24,21 +38,8 @@ async def test_a_slow_thread_open_uses_one_tab_and_ends_with_saved_history(works comms.registry.declare(comms.registry.new_thread(name, frozenset({"journey"}), str(project), process_identity=ProcessIdentity.capture(os.getpid()))) comms.channels.create_tag("journey") - comms.messaging.send_initial_cohort("owner", "#journey", "SAVED_CHANNEL_JOURNEY") - tab_sets_drawn = [] - - def started(**arguments): - app = ToadApp(agent_session_id="owner", **arguments) - draw = app._display - - def recording_draw(screen, renderable): - draw(screen, renderable) - if renderable is not None and not app._batch_count: - tab_sets_drawn.append(tuple(tab.mode_name for tab in app.open_tabs)) - - app._display = recording_draw - return app - + comms.messaging.send("owner", "#journey", "SAVED_CHANNEL_JOURNEY") + started = partial(TabFramesApp, agent_session_id="owner") async with workspace.app(size=(139, 25), binding=AgentAttached(peer("delayed_load")), application=started) as ui: app, pilot = ui.textual await ui.until_agent_ready() @@ -99,7 +100,7 @@ def journey_group(roster): # test_native_journey.py::test_a_second_thread_opened_from_the_app_answers_its_first_message. assert tuple(app.tab_order.names) == (*tabs_before, tab) assert all(len(drawn) <= 3 and not any(name.startswith("pending-") for name in drawn) - for drawn in tab_sets_drawn) + for drawn in app.tab_sets_drawn) finally: opening.cancel() duplicate.cancel() diff --git a/tests/support/routes.py b/tests/support/routes.py index 8e68a63f6..82083aa6e 100644 --- a/tests/support/routes.py +++ b/tests/support/routes.py @@ -6,19 +6,21 @@ import os from pathlib import Path +from agent_comms.active_route import ActiveRoute + def private_root(path: Path, project: Path, message: str) -> tuple[Path, str]: """A private wire at `path` holding `message` in #team from `peer`; returns it and its root id.""" from agent_comms.comms import Comms - from agent_comms.thread_identity import ThreadRole + from agent_comms.thread_identity import UserThreadRole path.mkdir(mode=0o700) - comms = Comms(path, private_initial_writes=True) - comms.registry.declare(comms.registry.new_thread("user", frozenset(), str(project), role=ThreadRole.USER)) + comms = Comms(path) + root_id = comms.messaging.initialize_private_initial_protocol() + comms.registry.declare(comms.registry.new_thread("user", frozenset(), str(project), role=UserThreadRole)) comms.registry.declare(comms.registry.new_thread("owner", frozenset({"team"}), str(project / "owner-project"))) comms.registry.declare(comms.registry.new_thread("peer", frozenset({"team"}), str(project / "peer-project"))) - root_id = comms.messaging.initialize_private_initial_protocol() - comms.messaging.send_initial_cohort("peer", "#team", message) + comms.messaging.send("peer", "#team", message) return path, root_id @@ -29,8 +31,7 @@ def publish_route(home: Path, root: Path, root_id: str) -> Path: directory.chmod(0o700) destination = directory / "active-route.json" replacement = directory / "replacement.json" - replacement.write_text(json.dumps({"version": 1, "root": str(root), "wire_root_id": root_id, - "native_package": str(home / "unused-package")})) + replacement.write_text(json.dumps(ActiveRoute(root, root_id).to_record())) replacement.chmod(0o600) os.replace(replacement, destination) return destination diff --git a/tests/textual/test_channel_participants.py b/tests/textual/test_channel_participants.py index a56fe672a..0d7643103 100644 --- a/tests/textual/test_channel_participants.py +++ b/tests/textual/test_channel_participants.py @@ -41,9 +41,10 @@ async def test_private_channel_update_changes_participants_without_blocking_the_ assert all(f"participant-{index:02}" in names.content.plain for index in range(PARTICIPANTS)) await ui.write_draft("Retained draft") - message = await asyncio.to_thread( - service.messaging.send_initial_cohort, + sent = await asyncio.to_thread( + service.messaging.send, "participant-00", "#team", "Private update") + message = sent.message await ui.until_shows("Private update") await ui.press("x") assert ui.draft() == "Retained draftx" diff --git a/tests/textual/test_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index 77e45fb57..0b44d0b6b 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -13,7 +13,7 @@ async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_ for name in ("sender", "reader", session_thread_name(workspace.project)): workspace.thread(name, "alpha") comms.messaging.initialize_private_initial_protocol() - comms.messaging.send_initial_cohort("sender", "#alpha", "INITIAL ROW") + comms.messaging.send("sender", "#alpha", "INITIAL ROW") reads = [] code = HistoryViews.message_notifications.__code__ @@ -34,12 +34,12 @@ def observe(frame, event, argument): await chat.message_history.toggle_style() await ui.until(lambda: bool(reads), why="the first notification read") - comms.messaging.send_initial_cohort("sender", "#alpha", "LIVE UPDATE") + comms.messaging.send("sender", "#alpha", "LIVE UPDATE") await ui.until_shows("LIVE UPDATE") await ui.select_tab(owner) parked = len(reads) - comms.messaging.send_initial_cohort("sender", "#alpha", "PARKED UPDATE") + comms.messaging.send("sender", "#alpha", "PARKED UPDATE") await ui.step(1.2) assert len(reads) == parked, "a parked channel read notification bodies" await ui.select_tab(mode) diff --git a/tests/textual/test_default_route_flip.py b/tests/textual/test_default_route_flip.py index 18bdfe8e4..1c8212fb3 100644 --- a/tests/textual/test_default_route_flip.py +++ b/tests/textual/test_default_route_flip.py @@ -35,7 +35,7 @@ async def test_a_late_read_from_the_former_wire_is_not_painted_after_the_route_f # A read already in flight when the route flips must not paint a late page # from the former wire into the successor view. - view.message_history.source.comms.messaging.send_initial_cohort("peer", "#team", "LATE-OLD-EDGE") + view.message_history.source.comms.messaging.send("peer", "#team", "LATE-OLD-EDGE") entered, release = threading.Event(), threading.Event() real_read = WireSource.read diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index 95d083885..53d3019f6 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -129,7 +129,7 @@ async def test_an_unavailable_route_is_no_evidence_that_a_hidden_thread_is_gone( async with workspace.app(size=(140, 48)) as ui: app, _ = ui.textual modes = await open_hidden_sessions(workspace, ui, owners) - other = Comms(workspace.roots.wire.parent / "other", private_initial_writes=True) + other = Comms(workspace.roots.wire.parent / "other") other.messaging.initialize_private_initial_protocol() monkeypatch.setenv("AGENT_COMMS_ROOT", str(other.root)) await app.session_navigation.retire_missing() diff --git a/tests/textual/test_historical_notification_source.py b/tests/textual/test_historical_notification_source.py index bb750cc9d..9001a2ecf 100644 --- a/tests/textual/test_historical_notification_source.py +++ b/tests/textual/test_historical_notification_source.py @@ -22,7 +22,7 @@ def test_historical_notification_keeps_selected_source_and_lifetime( old.registry.declare(old.registry.new_thread("agent", frozenset(), str(tmp_path), created_at=12.0)) live.registry.declare(live.registry.new_thread("agent", frozenset(), str(tmp_path), created_at=212.0)) old.messaging.initialize_private_initial_protocol() - messages = (old.messaging.send_initial_cohort("sender", "agent", "historical notification"),) + messages = (old.messaging.send("sender", "agent", "historical notification").message,) source = live.views.attach_history(old.root) item = HistoricalThread(source, source.provenance.require("agent")) published, errors, selected = [], [], [] diff --git a/tests/textual/test_stale_route_writes.py b/tests/textual/test_stale_route_writes.py deleted file mode 100644 index cf7a1c717..000000000 --- a/tests/textual/test_stale_route_writes.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Textual-facing: mounted view writes (read marks, thread actions) started before a route publication are refused after it.""" - -import asyncio -import threading - -import pytest -from unittest.mock import patch - -import agent_comms.private_nk_entrypoint as private_nk_entrypoint -from agent_comms.active_route import ActiveRoute, publish_active_route -from agent_comms.cli_commands import StartCliCommand -from agent_comms.comms import Comms -from agent_comms.route_selection import current_root, run_selected_write -from agent_comms.thread_identity import ThreadRole - -from support.routes import private_root, select_root_by_default_route -from support.textual_steps import refresh_chat, request_target_command - - -async def test_delayed_view_writes_wait_out_a_route_publication_and_are_then_refused(workspace, monkeypatch, native_package): - from toad import comms_root - from toad.widgets.comms_chat import CommsChatView - - attempt = workspace.roots.attempt - project = workspace.project - home = select_root_by_default_route(monkeypatch, attempt) - legacy = home / ".agent-comms" - legacy.mkdir(mode=0o700) - old = Comms(legacy) - old.registry.declare(old.registry.new_thread("user", frozenset(), str(project), role=ThreadRole.USER)) - old.registry.declare(old.registry.new_thread("peer", frozenset({"team"}), str(project / "peer"))) - old.messaging.send("peer", "#team", "LEGACY-PAINT") - private, private_id = private_root(attempt / "private", project, "PRIVATE-PAINT") - route = ActiveRoute(private, private_id, native_package) - assert current_root() == legacy - - async with workspace.app(size=(115, 38)) as ui: - app = ui.textual[0] - await ui.open_channel("#team", actor="user") - view = app.screen.query_one(CommsChatView) - await refresh_chat(view) - await ui.step() - assert view.message_history.source.comms.root == legacy - old.messaging.send("peer", "#team", "UNREAD-OLD") - page = old.views.channel_display_page("#team", worktree=str(project), limit=8) - assert page.newest_seq == 2 - marker = legacy / "read_markers.json" - before = marker.read_bytes() if marker.exists() else b"" - - entered_ack, entered_action = asyncio.Event(), threading.Event() - release = threading.Event() - original_to_thread = asyncio.to_thread - real_write = comms_root.run_selected_write - invoked = [] - - async def delayed_dispatch(operation, *args, **kwargs): - if operation is run_selected_write and getattr(args[1], "__name__", "") == "mark_channel_view_read": - entered_ack.set() - await original_to_thread(release.wait, 20) - return await original_to_thread(operation, *args, **kwargs) - - def delayed_write(root, operation, *args, **kwargs): - entered_action.set() - release.wait(20) - return real_write(root, operation, *args, **kwargs) - - def safe_spy(command, comms): - invoked.append(command.name) - return {"thread": command.name, "pid": 0, "launched": False} - - with (patch("asyncio.to_thread", delayed_dispatch), - patch.object(comms_root, "run_selected_write", delayed_write), - patch.object(StartCliCommand, "apply", safe_spy), - patch.object(private_nk_entrypoint, "_trusted_package", lambda _: None)): - history = view.message_history - ack = asyncio.create_task(history.mark_page(page, None, snapshot=history.source_snapshot())) - request_target_command(app, StartCliCommand, "peer") - async with asyncio.timeout(8): - while not (entered_ack.is_set() and entered_action.is_set()): - await original_to_thread(entered_action.wait, 0.05) - await asyncio.sleep(0) - # The delayed view writes have not taken the route's lock yet, so publication passes them. - async with asyncio.timeout(5): - await asyncio.get_running_loop().run_in_executor(None, publish_active_route, route) - assert current_root() == private - release.set() - await ack - await ui.step() - assert not invoked, "stale old-root start reached its core operation" - assert (marker.read_bytes() if marker.exists() else b"") == before - with pytest.raises(ValueError): - run_selected_write(legacy, safe_spy, None, None, implicit=True) From 1a4e4c5ee63a0e0cb4ca4115ffe5cdb022985d0f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:55:25 -0400 Subject: [PATCH 36/43] Remove retired participants test and observe current notifications --- tests/textual/test_channel_participants.py | 65 ------------------- .../test_channel_updates_and_returns.py | 2 +- 2 files changed, 1 insertion(+), 66 deletions(-) delete mode 100644 tests/textual/test_channel_participants.py diff --git a/tests/textual/test_channel_participants.py b/tests/textual/test_channel_participants.py deleted file mode 100644 index 0d7643103..000000000 --- a/tests/textual/test_channel_participants.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Textual-facing: the participants line above a channel composer is prepared off the UI thread (a Textual widget's paint input).""" - -import asyncio -from collections import Counter -import os -import sys -from threading import get_ident - -from agent_comms.child_process import ProcessIdentity -from agent_comms.thread_execution import ExternalThreadExecution - -PARTICIPANTS = 24 - - -async def test_private_channel_update_changes_participants_without_blocking_the_ui(workspace): - from toad.widgets.channel_participants import ChannelParticipants - - service = workspace.comms - service.messaging.initialize_private_initial_protocol() - leases = [] - for index in range(PARTICIPANTS): - owner = service.registry.declare(service.registry.new_thread( - f"participant-{index:02}", frozenset({"team"}), str(workspace.project), - process_identity=ProcessIdentity.capture(os.getpid()), execution=ExternalThreadExecution)) - leased, _ = service.registry.lease_local_turn(owner.name, f"private-turn-{index}") - leases.append(leased.require_turn_lease()) - - loop_thread = get_ident() - calls = Counter() - monitor = sys.monitoring - monitor.use_tool_id(monitor.PROFILER_ID, "channel-participants") - code = ChannelParticipants.prepare_participants.__code__ - monitor.register_callback(monitor.PROFILER_ID, monitor.events.PY_START, lambda *_: calls.update( - ("UI" if get_ident() == loop_thread else "worker",))) - monitor.set_local_events(monitor.PROFILER_ID, code, monitor.events.PY_START) - try: - async with workspace.app(size=(110, 38)) as ui: - await ui.open_channel("#team", actor="participant-00") - names = ui.textual[0].screen.query_one(ChannelParticipants).names - await ui.until(lambda: "participant-23" in names.content.plain, why="every participant listed") - assert all(f"participant-{index:02}" in names.content.plain for index in range(PARTICIPANTS)) - - await ui.write_draft("Retained draft") - sent = await asyncio.to_thread( - service.messaging.send, - "participant-00", "#team", "Private update") - message = sent.message - await ui.until_shows("Private update") - await ui.press("x") - assert ui.draft() == "Retained draftx" - - for lease in leases: - await asyncio.to_thread(service.registry.release_turn, lease) - leases.clear() - await ui.until(lambda: names.content.plain == "No active turns", why="participants to retire") - assert message.message_id - await ui.write_draft("@participant-23") - await ui.until_shows("Mention a thread") # Idle members remain mentionable. - assert calls["worker"] > 0 - assert calls["UI"] == 0 - finally: - for lease in leases: - await asyncio.to_thread(service.registry.release_turn, lease) - monitor.set_local_events(monitor.PROFILER_ID, code, 0) - monitor.free_tool_id(monitor.PROFILER_ID) diff --git a/tests/textual/test_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index 0b44d0b6b..b6aa44fb3 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -15,7 +15,7 @@ async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_ comms.messaging.initialize_private_initial_protocol() comms.messaging.send("sender", "#alpha", "INITIAL ROW") reads = [] - code = HistoryViews.message_notifications.__code__ + code = HistoryViews.message_notifications_for_references.__code__ def observe(frame, event, argument): if event == "call" and frame.f_code is code: From 4fe7738ebb73e263b755e81a8677f585ca516038 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:57:38 -0400 Subject: [PATCH 37/43] Check channel notification lifetime at its subscription owner --- .../test_channel_updates_and_returns.py | 67 +++++++------------ 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/tests/textual/test_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index b6aa44fb3..d5aff8b3d 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -1,11 +1,5 @@ """Textual-facing: a shown channel view paints live updates; a parked one reads nothing and shows its update on return (same widget).""" -import sys -import threading - -from agent_comms.history_views import HistoryViews - - async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_the_update(workspace): from toad.widgets.comms_chat import CommsChatView, session_thread_name @@ -13,38 +7,29 @@ async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_ for name in ("sender", "reader", session_thread_name(workspace.project)): workspace.thread(name, "alpha") comms.messaging.initialize_private_initial_protocol() - comms.messaging.send("sender", "#alpha", "INITIAL ROW") - reads = [] - code = HistoryViews.message_notifications_for_references.__code__ - - def observe(frame, event, argument): - if event == "call" and frame.f_code is code: - reads.append(1) - - old_profile, old_thread_profile = sys.getprofile(), threading.getprofile() - sys.setprofile(observe) - threading.setprofile(observe) - try: - async with workspace.app(size=(113, 34)) as ui: - app = ui.textual[0] - owner = ui.selected_tab - mode = await ui.open_channel("#alpha", actor="reader") - chat = app.selected_session.query_one(CommsChatView) - await ui.until_shows("INITIAL ROW") - await chat.message_history.toggle_style() - await ui.until(lambda: bool(reads), why="the first notification read") - - comms.messaging.send("sender", "#alpha", "LIVE UPDATE") - await ui.until_shows("LIVE UPDATE") - - await ui.select_tab(owner) - parked = len(reads) - comms.messaging.send("sender", "#alpha", "PARKED UPDATE") - await ui.step(1.2) - assert len(reads) == parked, "a parked channel read notification bodies" - await ui.select_tab(mode) - await ui.until_shows("PARKED UPDATE") - assert app.selected_session.query_one(CommsChatView) is chat - finally: - sys.setprofile(old_profile) - threading.setprofile(old_thread_profile) + initial = comms.messaging.send("sender", "#alpha", "INITIAL ROW").message + async with workspace.app(size=(113, 34)) as ui: + app = ui.textual[0] + access = app.coordination_access + owner = ui.selected_tab + mode = await ui.open_channel("#alpha", actor="reader") + chat = app.selected_session.query_one(CommsChatView) + await ui.until_shows("INITIAL ROW") + await chat.message_history.toggle_style() + await ui.until( + lambda: any(initial.reference in read.references for read in access.notification_interest.reads), + why="the shown channel to subscribe to its notification references", + ) + + comms.messaging.send("sender", "#alpha", "LIVE UPDATE") + await ui.until_shows("LIVE UPDATE") + + await ui.select_tab(owner) + await ui.until(lambda: not access.notification_interest.reads, why="the parked channel to unsubscribe") + comms.messaging.send("sender", "#alpha", "PARKED UPDATE") + await ui.step(1.2) + assert not access.notification_views and not access.notification_interest.reads + await ui.select_tab(mode) + await ui.until_shows("PARKED UPDATE") + await ui.until(lambda: bool(access.notification_interest.reads), why="the returned channel to resubscribe") + assert app.selected_session.query_one(CommsChatView) is chat From 7bd56f7cfc55e387de4017b834da5cfbccfb2575 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:25:03 -0400 Subject: [PATCH 38/43] Let session state and composed controls own their decisions --- src/toad/agent.py | 2 +- src/toad/agent_binding.py | 4 +- src/toad/cli.py | 8 ++- src/toad/conversation_comms_handlers.py | 4 +- src/toad/conversation_submission.py | 2 +- src/toad/delivery_failure_view.py | 22 +++----- src/toad/goal_interaction.py | 4 +- src/toad/screens/main.py | 10 ++-- src/toad/session_entry.py | 1 + src/toad/session_tracker.py | 67 +++++++++++-------------- src/toad/slash_command.py | 14 ++---- src/toad/widgets/conversation.py | 45 ++++++++--------- tests/model/test_session_name.py | 57 +++++++++++++++++++++ 13 files changed, 135 insertions(+), 105 deletions(-) create mode 100644 tests/model/test_session_name.py diff --git a/src/toad/agent.py b/src/toad/agent.py index b0b24a7fb..d42114491 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -48,7 +48,7 @@ async def reconnect_session(self, source, plan): async def change_project(self, view, path): project = await self.update_project(str(path)) - view.flash(Content(f"Project changed to {project}"), style="success") + view.feedback.flash(Content(f"Project changed to {project}"), style="success") def request_compaction(self, view, instructions): view.compact_context(instructions) diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index ceaab89ce..19af00e5c 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -149,10 +149,10 @@ async def reconnect_session(self, source, plan): source.presentation.sources.attach_started(source, plan) async def change_project(self, view, path): - view.flash("Project changes require an agent-comms session", style="error") + view.feedback.flash("Project changes require an agent-comms session", style="error") def request_compaction(self, view, instructions): - view.flash("Context compaction requires an agent-comms session", style="error") + view.feedback.flash("Context compaction requires an agent-comms session", style="error") async def observe_thread_presentation(self, presentation): """There is no connected agent to inform.""" diff --git a/src/toad/cli.py b/src/toad/cli.py index a0bd120a4..6ebcd4e36 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -63,18 +63,16 @@ async def get_agent_binding(launch_agent: str) -> AgentBinding: try: agents = await read_agents() except AgentReadError: - agents = {} + return NoAgent() for agent_data in agents.values(): if ( agent_data.short_name.lower() == launch_agent or agent_data.identity.lower() == launch_agent ): - launch_agent = agent_data.identity - break + return AgentAttached(agent_data) - selected = agents.get(launch_agent) - return NoAgent() if selected is None else AgentAttached(selected) + return NoAgent() class DefaultCommandGroup(click.Group): diff --git a/src/toad/conversation_comms_handlers.py b/src/toad/conversation_comms_handlers.py index 1b70a0670..86bacd700 100644 --- a/src/toad/conversation_comms_handlers.py +++ b/src/toad/conversation_comms_handlers.py @@ -107,14 +107,14 @@ def on_input_failed( if not message.event.recover_draft: # Server notices (including unstarted queued/restored inputs) are # read-only evidence, not permission to change the local composer. - self.flash( + self.feedback.flash( f"{message.event.update.failure.title}: {message.event.update.failure.description}\n{message.event.update.failure.input_disposition}\n{message.event.update.failure.action}", style="error", ) return self.restore_user_message(message.event.update.text) - self.flash( + self.feedback.flash( f"{message.event.update.failure.title}; draft restored: {message.event.update.failure.description}\n{message.event.update.failure.input_disposition}\n{message.event.update.failure.action}", style="error", ) diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 41b7ff59b..47cede55e 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -366,7 +366,7 @@ async def _send_now(self, agent, session, scope): return except RequestError as error: if self.view.agent_binding is agent and agent.session_id == session: - self.view.flash(f"Send now failed: {error}", style="error") + self.view.feedback.flash(f"Send now failed: {error}", style="error") if self.view.agent_binding is agent and agent.queue_attachment.accepts_request( scope ): diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index b3d44fb14..4c8448975 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -7,7 +7,6 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from pathlib import Path -from typing import Literal from agent_comms.acp_extension import UnknownDeliveryFailure from agent_comms.errors import HumanSendError @@ -90,6 +89,7 @@ class DeliveryFailureView(ToadNode, Widget): """The composed input, status and delivery behavior shared by conversations.""" prompt = getters.query_one(Prompt) + feedback = getters.query_one(Flash) status: var[str | Content | RichText] = var("") @abstractmethod @@ -99,16 +99,6 @@ async def composer_read_only(self) -> bool: """Native and CLI sessions compose to their exact thread, not a saved channel.""" return False - def flash( - self, - content: str | Content, - *, - duration: float | None = None, - style: Literal["default", "warning", "error", "success"] = "default", - ) -> None: - """Show feedback in this view's composed flash control.""" - self.query_one(Flash).flash(content, duration=duration, style=style) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._send_state: SendState = NoSend() @@ -153,7 +143,7 @@ async def submit_comms_message(self, root, conversation, body, current, present) reason = await self.refresh_composer(read_only) if read_only: self.restore_user_message(body) - self.flash(reason, style="error") + self.feedback.flash(reason, style="error") return references = ( self.app.coordination_access.observation.observed() @@ -170,7 +160,7 @@ async def deliver_user_message(self, root, target, body, current, present): if self.send_block_reason: self.restore_user_message(body) - self.flash(self.send_block_reason, style="error") + self.feedback.flash(self.send_block_reason, style="error") return self._enter_send_state(SendPending()) @@ -236,14 +226,14 @@ def restore_user_message(self, body: str) -> None: def delivery_unknown(self, failure: UnknownDeliveryFailure, body: str) -> None: self.restore_user_message(body) self._enter_send_state(SendUnknown(failure)) - self.flash(self.send_block_reason, style="error") + self.feedback.flash(self.send_block_reason, style="error") def delivery_blocked(self, description: str, body: str) -> None: self.restore_user_message(body) self._enter_send_state(SendBlocked(description)) - self.flash(self.send_block_reason, style="error") + self.feedback.flash(self.send_block_reason, style="error") def delivery_refused(self, description: str, body: str) -> None: self.restore_user_message(body) self._enter_send_state(NoSend()) - self.flash(f"Send failed: {description}", style="error") + self.feedback.flash(f"Send failed: {description}", style="error") diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index 257e55473..1212e8d60 100644 --- a/src/toad/goal_interaction.py +++ b/src/toad/goal_interaction.py @@ -86,7 +86,7 @@ async def apply(cls, session): if isinstance(execution, ControlledExecution): await session.change(execution.toggle) else: - session.view.flash(session.display.heading(execution)) + session.view.feedback.flash(session.display.heading(execution)) class EditInteraction(GoalInteraction): @@ -155,7 +155,7 @@ async def activate(self, action: type[GoalInteraction]) -> None: if not self.owns(agent): return if not action.enabled(self): - view.flash(view.goal_display.heading(view.goal_execution)) + view.feedback.flash(view.goal_display.heading(view.goal_execution)) return await action.apply(self) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index c52ae2f15..6a263773a 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -43,7 +43,7 @@ from toad.screens.session_view import SessionView from toad.session_presentation import OperationalSessionPresentation from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin -from toad.session_tracker import SidebarState +from toad.session_tracker import SessionDetails, SidebarState from toad.thread_identity import NoThreadIdentity, ThreadIdentity from toad.widgets.channels_sidebar import ChannelsSidebar from toad.widgets.conversation import Conversation @@ -363,16 +363,16 @@ def retained_native_presentations(self): def session_disposition(self) -> SessionDisposition: return self.presentation.operational_binding.session_disposition + session_details: SessionDetails + """The registered entry supplies its existing details before mounting this view.""" + def make_conversation(self) -> Conversation: - assert ( - self.id is not None - ), "Conversation construction requires a registered session" return self._agent_binding.make_conversation( partial( Conversation, self.project_path, session_pk=self._session_pk, - session_title=self.app.session_tracker.sessions[self.id].initial_title, + session_title=self.session_details.initial_title, initial_prompt=self._initial_prompt, transcript=self.presentation.transcript, ) diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index c3befa0cf..07579523f 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -128,6 +128,7 @@ def original_threads(self, sessions): def __call__(self) -> MainScreen: source = self.factory() + source.session_details = self.details source.thread_identity.bind_initial_identity(self.details) return source diff --git a/src/toad/session_tracker.py b/src/toad/session_tracker.py index 770c9d309..2be0f5742 100644 --- a/src/toad/session_tracker.py +++ b/src/toad/session_tracker.py @@ -104,47 +104,39 @@ class SidebarState: @dataclass(frozen=True) class SessionName: - """A session tab's name: the user's choice, or the thread's title.""" + """A session label owns whether presented thread names can replace it.""" - authored: str | None - thread_title: str = "" - - @property - def display(self) -> str: - """What the tab shows: the user's name when they chose one.""" - return self.authored or self.thread_title + display: str @property def is_placeholder(self) -> bool: - """No name yet: not authored, and still showing the workspace placeholder.""" - return self.authored is None and self.thread_title == "New Session" + return False + + def present_thread_title(self, thread_title: str) -> SessionName: + return self + + def adopt_identity(self, previous: str, current: str) -> SessionName: + return self - @classmethod - def authored_name(cls, name: str) -> SessionName: - return cls(name) - @classmethod - def following(cls, thread_title: str) -> SessionName: - return cls(None, thread_title) +class AuthoredSessionName(SessionName): + """The user's label stays when the thread presents a title or changes name.""" - def present_thread_title(self, thread_title: str) -> SessionName | None: - """The name after the thread presents a title, or None if it cannot win.""" - if self.authored is not None: - return None - return type(self)(None, thread_title) - def adopt_identity(self, previous: str, current: str) -> SessionName | None: - """The thread behind this session is now named `current`. +class ThreadSessionName(SessionName): + """An unlabelled session follows its thread's presented title and identity.""" + + @property + def is_placeholder(self) -> bool: + return self.display == "New Session" + + def present_thread_title(self, thread_title: str) -> SessionName: + return type(self)(thread_title) - A tab that shows no name of its own yet, or still shows the old - thread's name, follows; the user's name and a tab already following - another thread stay. - """ - if self.authored is not None: - return None - if self.thread_title != previous and not self.is_placeholder: - return None - return type(self)(None, current) + def adopt_identity(self, previous: str, current: str) -> SessionName: + if self.display == previous or self.is_placeholder: + return type(self)(current) + return self @dataclass @@ -156,7 +148,7 @@ class SessionDetails: mode_name: str """The screen mode name.""" name: SessionName = field( - default_factory=lambda: SessionName.following("New Session") + default_factory=lambda: ThreadSessionName("New Session") ) """The name of the conversation: the user's choice or the thread's title.""" subtitle: str = "" @@ -181,12 +173,12 @@ def bind_initial_identity(self, thread_name: str) -> None: def name_session(self, name: str) -> None: """The user named this session; no presented title replaces it.""" - self.name = SessionName.authored_name(name) + self.name = AuthoredSessionName(name) def update_thread_title(self, thread_title: str) -> bool: """Adopt the thread's title where the user has not named the session.""" replacement = self.name.present_thread_title(thread_title) - if replacement is None or replacement.thread_title == self.name.thread_title: + if replacement == self.name: return False self.name = replacement return True @@ -210,7 +202,7 @@ def new_session(self, *, title: str = "New Session") -> SessionDetails: session_meta = SessionDetails( index=self._session_index, mode_name=mode_name, - name=SessionName.following(title), + name=ThreadSessionName(title), ) self.sessions[mode_name] = session_meta self.events.publish(SessionChangedEvent(mode_name)) @@ -224,7 +216,8 @@ def close_session(self, mode_name: str) -> None: def bind_identity(self, mode_name: str, previous: str, current: str) -> None: """The thread's name arrives: the user's name keeps the tab's label.""" details = self.sessions[mode_name] - if (adopted := details.name.adopt_identity(previous, current)) is not None: + adopted = details.name.adopt_identity(previous, current) + if adopted != details.name: details.name = adopted self.events.publish(SessionChangedEvent(mode_name)) diff --git a/src/toad/slash_command.py b/src/toad/slash_command.py index 954bac83f..d5ade00b5 100644 --- a/src/toad/slash_command.py +++ b/src/toad/slash_command.py @@ -141,7 +141,7 @@ def parse(cls, arguments: str) -> Self: async def apply(self, conversation: Conversation) -> bool: if self.path is None: - conversation.flash(Content(f"Project: {conversation.project_path}")) + conversation.feedback.flash(Content(f"Project: {conversation.project_path}")) else: await conversation.agent_binding.change_project(conversation, self.path) return True @@ -193,7 +193,7 @@ async def apply(self, conversation: Conversation) -> bool: await conversation.goal_controls.change(SetGoalAction, self.objective) else: await conversation.goal_observation.refresh() - conversation.flash( + conversation.feedback.flash( f"Use /goal {self.hint} to set, edit or manage this thread's goal." ) return True @@ -211,7 +211,7 @@ def parse(cls, arguments: str) -> Self: async def apply(self, conversation: Conversation) -> bool: if not conversation.turns.owner.can_compact: - conversation.flash( + conversation.feedback.flash( "Wait for the current response before compacting", style="error" ) else: @@ -226,7 +226,7 @@ async def apply(self, conversation: Conversation) -> bool: if conversation.model_selection_available: conversation.prompt.model_switcher.focus() else: - conversation.flash("This agent has no model selector", style="error") + conversation.feedback.flash("This agent has no model selector", style="error") return True @@ -282,7 +282,7 @@ def parse(cls, arguments: str) -> Self: async def apply(self, conversation: Conversation) -> bool: await conversation.rename_session(self.name) - conversation.flash(f"Renamed session to [b]'{self.name}'", style="success") + conversation.feedback.flash(f"Renamed session to [b]'{self.name}'", style="success") return True @@ -290,8 +290,6 @@ class SessionCloseCommand(NoArgumentsCommand, declared_name="toad:session-close" help = "Close the current session" async def apply(self, conversation: Conversation) -> bool: - from toad import messages - if conversation.turns.owner.busy: await conversation.agent_binding.cancel() if conversation.screen.id is not None: @@ -310,8 +308,6 @@ def parse(cls, arguments: str) -> Self: return cls(arguments.strip()) async def apply(self, conversation: Conversation) -> bool: - from toad import messages - if conversation._agent_data is not None: conversation.app.session_navigation.events.publish( session_requests.SessionNew( diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index b4047568b..f8c1dedc2 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -471,21 +471,21 @@ def apply_bound_agent(self, agent: AgentBase) -> None: self.call_later(self.delivery_observation.invalidate) def agent_connected(self, agent: AgentBase) -> None: - self.flash(Content.assemble(agent.get_info(), " connected"), style="success") + self.feedback.flash(Content.assemble(agent.get_info(), " connected"), style="success") if self._agent_data is not None: self.app.application.usage.publish( "agent-session-begin", agent=self._agent_data.identity ) async def cancel_bound_turn(self, agent: AgentBase) -> None: - self.flash("Cancelling agent turn…") + self.feedback.flash("Cancelling agent turn…") self.turns.describe("Cancelling…") if self._loading is not None and self._loading.is_attached: self._loading.update("Cancelling…") if await agent.cancel(): - self.flash("Turn cancelled", style="success") + self.feedback.flash("Turn cancelled", style="success") else: - self.flash("Agent declined to cancel. Please wait.", style="error") + self.feedback.flash("Agent declined to cancel. Please wait.", style="error") @abstractmethod def _turn_changed(self, owner: TurnOwner) -> None: @@ -1017,7 +1017,7 @@ async def on_shell_terminal_terminate(self, event: ShellTerminal.Terminate) -> N if not event.teminal.is_finalized: await self.shell.interrupt() self.navigation.clear() - self.flash("Command interrupted", style="success") + self.feedback.flash("Command interrupted", style="success") def check_directory(self) -> None: """A finished turn or command may have changed the project's files.""" @@ -1041,7 +1041,7 @@ def on_terminal_long_running(self, event: Terminal.LongRunning) -> None: and not event.terminal.has_focus and not event.terminal.state.buffer.is_blank ): - self.flash("Press [b]ctrl+f[/b] to focus command", style="default") + self.feedback.flash("Press [b]ctrl+f[/b] to focus command", style="default") @on(Terminal.AlternateScreenChanged) def on_terminal_alternate_screen_( @@ -1055,7 +1055,7 @@ def on_terminal_alternate_screen_( @on(events.DescendantFocus, "Terminal") def on_terminal_focus(self, event: events.DescendantFocus) -> None: - self.flash("Press [b]escape[/b] [i]twice[/] to exit terminal", style="success") + self.feedback.flash("Press [b]escape[/b] [i]twice[/] to exit terminal", style="success") @on(events.DescendantBlur, "Terminal") def on_terminal_blur(self, event: events.DescendantFocus) -> None: @@ -1064,7 +1064,7 @@ def on_terminal_blur(self, event: events.DescendantFocus) -> None: @on(messages.Flash) def on_flash(self, event: messages.Flash) -> None: event.stop() - self.flash(event.content, duration=event.duration, style=event.style) + self.feedback.flash(event.content, duration=event.duration, style=event.style) @property def terminal_action_state(self) -> bool | None: @@ -1082,10 +1082,6 @@ def navigation(self) -> ContentNavigation: def cursor_block(self) -> ConversationEntry | None: return self.navigation.current - @property - def cursor_block_child(self) -> Widget | None: - return self.navigation.selected - def draws_user_message(self, sent) -> bool: from agent_comms.message_reference import MessageReference @@ -1168,7 +1164,7 @@ async def action_provider_login(self) -> None: agent = self.agent_binding methods = agent.presentation.auth_methods if not methods: - self.flash("This agent has not advertised any login methods", style="error") + self.feedback.flash("This agent has not advertised any login methods", style="error") return method_id = await ContextMenu.choose( self.screen, @@ -1220,7 +1216,7 @@ async def action_provider_login(self) -> None: await agent.session.reconnect_after_login() else: await agent.session.authenticate(method_id) - self.flash( + self.feedback.flash( "Provider login finished; model catalogue refreshed", style="success" ) finally: @@ -1270,7 +1266,7 @@ async def on_change_model(self, event: CoreEventMessage) -> None: ): self.app.show_error(error, "Set thinking level") return - self.flash( + self.feedback.flash( Content.from_markup( "Model [b]$model[/] · thinking [b]$level", model=model.name, @@ -1345,8 +1341,7 @@ async def on_menu_option_selected(self, event: Menu.OptionSelected) -> None: and self.navigation.accepts_menu_owner(event.owner) ): await self.run_action(event.action, {"block": event.owner}) - if self.cursor_block_child is not None: - self.call_after_refresh(self.cursor.refresh) + self.call_after_refresh(self.cursor.refresh) @handles(CurrentWorkingDirectoryChanged) def on_current_working_directory_changed(self, event: CoreEventMessage) -> None: @@ -1456,7 +1451,7 @@ async def action_interrupt(self) -> None: if terminal is not None and not terminal.is_finalized: await self.shell.interrupt() # self._shell = None - self.flash("Command interrupted", style="success") + self.feedback.flash("Command interrupted", style="success") else: raise SkipAction() @@ -1474,7 +1469,7 @@ async def set_mode(self, mode_id: str | None) -> None: if error is not None: self.app.show_error(error, "Set Mode") elif (mode := agent.current_mode) is not None: - self.flash( + self.feedback.flash( Content.from_markup("Mode changed to [b]$mode", mode=mode.name), style="success", ) @@ -1651,9 +1646,9 @@ async def on_goal_control(self, event: CoreEventMessage): async def compact_context(self, instructions: str | None) -> None: """Keep processing owner notifications while compaction is in flight.""" self.window.anchor() - self.flash("Compaction requested") + self.feedback.flash("Compaction requested") await self.agent_binding.compact_context(instructions) - self.flash("Context compacted", style="success") + self.feedback.flash("Context compacted", style="success") def open_queue_menu(self) -> None: """Remote edits require exact input IDs and backend revision-CAS support.""" @@ -1661,7 +1656,7 @@ def open_queue_menu(self) -> None: self._queue_edit_unavailable() def _queue_edit_unavailable(self) -> None: - self.flash( + self.feedback.flash( "Remote queue editing unavailable: requires ID-targeted, revision-checked backend support", style="error", ) @@ -1902,7 +1897,7 @@ async def cancel_turn(self, shortcut: str) -> None: await self.agent_binding.cancel_turn(self) self._last_escape_time = 0.0 else: - self.flash(f"Press [b]{shortcut}[/] again to cancel agent's turn") + self.feedback.flash(f"Press [b]{shortcut}[/] again to cancel agent's turn") self._last_escape_time = monotonic() def focus_prompt(self, reset_cursor: bool = True, scroll_end: bool = True) -> None: @@ -1951,13 +1946,13 @@ def action_copy_to_clipboard(self) -> None: 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") + self.feedback.flash("Copied to clipboard") def action_copy_to_prompt(self) -> None: 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.feedback.flash("Copied to prompt") self.focus_prompt() def action_maximize_block(self) -> None: diff --git a/tests/model/test_session_name.py b/tests/model/test_session_name.py new file mode 100644 index 000000000..212452bed --- /dev/null +++ b/tests/model/test_session_name.py @@ -0,0 +1,57 @@ +"""Session labels own whether thread presentation and identity may replace them.""" + +import pytest + +from toad.session_tracker import ( + AuthoredSessionName, + SessionDetails, + SessionTracker, + ThreadSessionName, +) + + +@pytest.mark.parametrize( + "name,expected", + [ + (AuthoredSessionName("Chosen"), "Chosen"), + (AuthoredSessionName("New Session"), "New Session"), + (ThreadSessionName("New Session"), "Presented title"), + (ThreadSessionName("Old title"), "Presented title"), + (ThreadSessionName("Presented title"), "Presented title"), + ], +) +def test_session_label_owns_presented_title(name, expected): + details = SessionDetails(index=1, mode_name="session-1", name=name) + assert details.update_thread_title("Presented title") == (name.display != expected) + assert details.name.display == expected + + +@pytest.mark.parametrize( + "name,expected", + [ + (AuthoredSessionName("old"), "old"), + (AuthoredSessionName("New Session"), "New Session"), + (ThreadSessionName("New Session"), "new"), + (ThreadSessionName("old"), "new"), + (ThreadSessionName("Unrelated title"), "Unrelated title"), + ], +) +def test_session_label_owns_identity_change(name, expected): + assert name.adopt_identity("old", "new").display == expected + + +def test_session_tracker_publishes_only_changed_labels(): + tracker = SessionTracker() + details = tracker.new_session(title="old") + changes = [] + with tracker.events.subscribe(lambda event, _subscription: changes.append(event)): + tracker.bind_identity(details.mode_name, "old", "old") + assert changes == [] + tracker.bind_identity(details.mode_name, "old", "new") + assert details.name.display == "new" + assert len(changes) == 1 + tracker.name_session(details.mode_name, "Chosen") + assert len(changes) == 2 + tracker.bind_identity(details.mode_name, "new", "renamed") + assert details.name.display == "Chosen" + assert len(changes) == 2 From 77b2b89265e275a9362412a950cf0989ac70dacd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:33:09 -0400 Subject: [PATCH 39/43] Delete the unused selection accessor and finish P401 checks --- src/toad/block_navigation.py | 5 ----- src/toad/screens/store.py | 1 - src/toad/status_thread.py | 16 +++++++++++++--- tests/model/test_session_name.py | 10 ++++++++-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 68dea2743..26de6263d 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -371,11 +371,6 @@ def clear(self) -> None: def cursor(self) -> BlockCursor | None: return self.current.block_cursor.active if self.current is not None else None - @property - def selected(self): - cursor = self.cursor - return None if cursor is None else cursor.selected - def move(self, direction: CursorDirection) -> None: blocks = self.blocks if not blocks: diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index 8015c8cff..987c723a5 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -28,7 +28,6 @@ from toad.app import ToadApp from toad.format_path import format_path from toad.pill import pill -from toad import messages from toad.widgets.directory_input import DirectoryInput from toad.widgets.mandelbrot import Mandelbrot from toad.widgets.condensed_path import CondensedPath diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py index 01c7e2fd6..b2cba98c6 100644 --- a/src/toad/status_thread.py +++ b/src/toad/status_thread.py @@ -25,7 +25,11 @@ class StatusThread(ABC): @staticmethod def route(root: Path, access) -> type[RoutedStatusThread]: - return LocalStatusThread if access.observation.observed().observes(root) else OtherRouteStatusThread + return ( + LocalStatusThread + if access.observation.observed().observes(root) + else OtherRouteStatusThread + ) @classmethod def for_root(cls, root: Path, name: str, access) -> StatusThread: @@ -69,7 +73,11 @@ def conversation(cls, root: Path, conversation, access) -> RoutedStatusThread: . class LocalStatusThread(RoutedStatusThread): @classmethod def conversation(cls, root: Path, conversation, access) -> LocalStatusThread: - references = access.observation.observed().sidebar.observation.require_snapshot().references + references = ( + access.observation.observed() + .sidebar.observation.require_snapshot() + .references + ) return cls(root, conversation.destination(references).name) def bind(self, activity: ObservedThreadActivity) -> None: @@ -81,7 +89,9 @@ def observe(self, activity: ObservedThreadActivity) -> None: access.events, ThreadRowsChanged, activity.status_changed, - factory=partial(ThreadCallbackSubscription, scope=(str(self.root), self.name)), + factory=partial( + ThreadCallbackSubscription, scope=(str(self.root), self.name) + ), ) diff --git a/tests/model/test_session_name.py b/tests/model/test_session_name.py index 212452bed..6cb81edf2 100644 --- a/tests/model/test_session_name.py +++ b/tests/model/test_session_name.py @@ -2,6 +2,7 @@ import pytest +from toad.core.events import SessionChangedEvent from toad.session_tracker import ( AuthoredSessionName, SessionDetails, @@ -40,11 +41,16 @@ def test_session_label_owns_identity_change(name, expected): assert name.adopt_identity("old", "new").display == expected +class SessionChanges(list[SessionChangedEvent]): + def record(self, event: SessionChangedEvent, _subscription) -> None: + self.append(event) + + def test_session_tracker_publishes_only_changed_labels(): tracker = SessionTracker() details = tracker.new_session(title="old") - changes = [] - with tracker.events.subscribe(lambda event, _subscription: changes.append(event)): + changes = SessionChanges() + with tracker.events.subscribe(SessionChangedEvent, changes.record): tracker.bind_identity(details.mode_name, "old", "old") assert changes == [] tracker.bind_identity(details.mode_name, "old", "new") From 8da6f2b494215a270b7c7dcca880f5328b071772 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:49:27 -0400 Subject: [PATCH 40/43] Place mounted session checks in the frontend test layer --- docs/agents/TESTS.md | 2 +- tests/guards/test_test_layers.py | 6 ------ tests/{app => textual}/test_agent_binding.py | 0 tests/{app => textual}/test_other_route_status.py | 5 ++++- tests/{app => textual}/test_session_scope.py | 0 tests/{app => textual}/test_welcome_view.py | 0 6 files changed, 5 insertions(+), 8 deletions(-) rename tests/{app => textual}/test_agent_binding.py (100%) rename tests/{app => textual}/test_other_route_status.py (90%) rename tests/{app => textual}/test_session_scope.py (100%) rename tests/{app => textual}/test_welcome_view.py (100%) diff --git a/docs/agents/TESTS.md b/docs/agents/TESTS.md index e304e20ef..61fac068f 100644 --- a/docs/agents/TESTS.md +++ b/docs/agents/TESTS.md @@ -32,7 +32,7 @@ The directory says what a test depends on. `tests/guards/test_test_layers.py` en |---|---|---| | `tests/model` | Behaviour with no interface: Core answers, line blocks, view models, codecs, session and agent protocol objects | `agent_comms`, `ui_theme`, frontend-neutral `toad` modules, `tmp_path`. Never `textual`, `toad.widgets`, `toad.screens`, `toad.app` | | `tests/app` | The running app, asked only questions a second frontend could answer | `workspace`, `Ui` questions, Core reads. Never `textual`, widget classes, `ui.textual` | -| `tests/textual` | Behaviour owned by the Textual frontend: paint, layout, strips, compositor, widget classes. The module docstring starts with `Textual-facing:` and says what Textual owns here | everything, through `ui.textual` | +| `tests/textual` | Behaviour owned by the Textual frontend: paint, layout, strips, compositor, widget classes. The module docstring says which frontend behavior it exercises | everything, through `ui.textual` | | `tests/native` | The real native agent package | `native_package` (skips with its reason when absent) | | `tests/terminal` | Toad in a real pseudo-terminal | `terminal` (skips when the emulator is absent) | | `tests/guards` | Source checks: deleted mechanisms stay deleted, layers stay separate | `ast`, the source tree | diff --git a/tests/guards/test_test_layers.py b/tests/guards/test_test_layers.py index 72c5219ac..615d428eb 100644 --- a/tests/guards/test_test_layers.py +++ b/tests/guards/test_test_layers.py @@ -51,12 +51,6 @@ def test_app_tests_ask_only_neutral_questions(): f"{path.name} reaches Textual through ui.{node.attr}; it belongs in tests/textual") -def test_textual_tests_say_what_textual_owns(): - for path in modules("textual"): - docstring = ast.get_docstring(ast.parse(path.read_text())) or "" - assert docstring.startswith("Textual-facing:"), f"{path.name} must open with 'Textual-facing: '" - - def test_source_names_no_machine_path(): for path in sorted((TESTS.parent / "src").rglob("*.py")): assert "/" + "home/" not in path.read_text(), f"{path} names a machine path" diff --git a/tests/app/test_agent_binding.py b/tests/textual/test_agent_binding.py similarity index 100% rename from tests/app/test_agent_binding.py rename to tests/textual/test_agent_binding.py diff --git a/tests/app/test_other_route_status.py b/tests/textual/test_other_route_status.py similarity index 90% rename from tests/app/test_other_route_status.py rename to tests/textual/test_other_route_status.py index 1101b8dbf..d4e79ac8c 100644 --- a/tests/app/test_other_route_status.py +++ b/tests/textual/test_other_route_status.py @@ -36,5 +36,8 @@ async def test_other_route_binds_its_status_without_starting_a_read(workspace): await app.selected_session.mount(surface) source.refresh(surface) assert surface.thread_activity.row == ThreadStatusModel.other_route("peer") - assert not any(worker.node is surface for worker in app.workers) + assert not any( + surface in worker.node.walk_ancestors(with_self=True) + for worker in app.workers + ) assert not other_root.exists() diff --git a/tests/app/test_session_scope.py b/tests/textual/test_session_scope.py similarity index 100% rename from tests/app/test_session_scope.py rename to tests/textual/test_session_scope.py diff --git a/tests/app/test_welcome_view.py b/tests/textual/test_welcome_view.py similarity index 100% rename from tests/app/test_welcome_view.py rename to tests/textual/test_welcome_view.py From c27ccdb5b5023ba7e44cf012a1f537d638b72548 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 22:51:15 -0400 Subject: [PATCH 41/43] Exercise route startup and historical notifications through real views --- tests/textual/test_default_route_flip.py | 162 +++++------------- .../test_historical_notification_source.py | 139 +++++++++------ 2 files changed, 129 insertions(+), 172 deletions(-) diff --git a/tests/textual/test_default_route_flip.py b/tests/textual/test_default_route_flip.py index 1c8212fb3..906bcc7f8 100644 --- a/tests/textual/test_default_route_flip.py +++ b/tests/textual/test_default_route_flip.py @@ -1,126 +1,54 @@ -"""Textual-facing: a mounted chat view (widget state: display, composer, status) follows the default comms route when it flips.""" +"""Textual-facing: an open chat keeps its startup route; a later launch selects the newly published route.""" -import asyncio -import threading -from unittest.mock import patch - -from agent_comms.errors import HumanClaimBlockedError -from agent_comms.route_selection import root_is_current +from agent_comms.active_route import ActiveRoute +from agent_comms.comms import Comms +from agent_comms.launch_root import LaunchRoot +from agent_comms.route_selection import RouteSelection from support.routes import private_root, publish_route, select_root_by_default_route -from support.textual_steps import refresh_chat -from toad.wire_history import WireSource - -async def test_a_late_read_from_the_former_wire_is_not_painted_after_the_route_flips(workspace, monkeypatch): - from toad.widgets.comms_chat import CommsChatView - from toad.widgets.comms_sidebar import CommsSidebar - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - first, first_id = private_root(attempt / "first", workspace.project, "OLD-WIRE-ONLY") - second, second_id = private_root(attempt / "second", workspace.project, "NEW-WIRE-ONLY") +async def test_open_chat_keeps_its_route_and_a_new_launch_selects_the_published_route( + workspace, monkeypatch +): + home = select_root_by_default_route(monkeypatch, workspace.roots.attempt) + first, first_id = private_root( + workspace.roots.attempt / "first", workspace.project, "OLD-WIRE-ONLY" + ) + second, second_id = private_root( + workspace.roots.attempt / "second", workspace.project, "NEW-WIRE-ONLY" + ) publish_route(home, first, first_id) - async with workspace.app(size=(115, 38)) as ui: - app = ui.textual[0] - assert app.coordination_access.service.root == first - mode = await ui.open_channel("#team", actor="user") - assert mode == ui.selected_tab - view = app.screen.query_one(CommsChatView) - await refresh_chat(view) - await ui.step() - assert view.message_history.source.comms.root == first - assert any("OLD-WIRE-ONLY" in str(message) for message in view.message_history.messages), view.message_history.messages - - # A read already in flight when the route flips must not paint a late page - # from the former wire into the successor view. - view.message_history.source.comms.messaging.send("peer", "#team", "LATE-OLD-EDGE") - entered, release = threading.Event(), threading.Event() - real_read = WireSource.read + with LaunchRoot.current().selected(): + LaunchRoot.startup([], ActiveRoute.published_launch) + async with workspace.app(size=(115, 38)) as ui: + app, _ = ui.textual + owner = ui.selected_tab + mode = await ui.open_channel("#team", actor="user") + chat = ui.conversation + await ui.until_shows("OLD-WIRE-ONLY") + await ui.write_draft("THE FIRST ROUTE DRAFT") - def delayed_read(source, request, through): - entered.set() - assert release.wait(8), "the held read was not released" - return real_read(source, request, through) - - with patch.object(WireSource, "read", delayed_read): - pending = asyncio.create_task(refresh_chat(view)) - assert await asyncio.to_thread(entered.wait, 5) publish_route(home, second, second_id) - release.set() - await pending - await ui.until(lambda: view.message_history.state.accepts_source_work, why="the held read to end") - # The route check runs once a second; the app's observed service follows it. - await ui.until(lambda: app.coordination_access.service.root == second, why="the app to follow the route") - assert all("LATE-OLD-EDGE" not in str(message) for message in view.message_history.messages) - await refresh_chat(view) - async with asyncio.timeout(10): - await app.coordination_access.current_sidebar() - await ui.step() - assert not root_is_current(first) - assert not view.display - if app.screen.query_one(CommsSidebar).display: - # The shared roster may already publish the successor while this old view stays hidden. - assert app.coordination_access.observed_service.root == second - assert not ui.shows("OLD-WIRE-ONLY") - - -async def test_a_fresh_connection_after_a_flip_paints_only_the_newly_published_wire_and_keeps_rejected_drafts_editable( - workspace, monkeypatch): - from toad.core import input_events - from toad.widgets.comms_chat import CommsChatView - - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - first, first_id = private_root(attempt / "first", workspace.project, "OLD-WIRE-ONLY") - second, second_id = private_root(attempt / "second", workspace.project, "NEW-WIRE-ONLY") - route_path = publish_route(home, first, first_id) - - # The same process first shows the former wire, then the route flips and that app closes. - async with workspace.app(size=(115, 38)) as ui: - await ui.open_channel("#team", actor="user") - await ui.until_shows("OLD-WIRE-ONLY") - publish_route(home, second, second_id) - await ui.until(lambda: ui.textual[0].coordination_access.service.root == second, why="the app to follow the route") - - async with workspace.app(size=(115, 38)) as ui: - app = ui.textual[0] - assert app.coordination_access.service.root == second - await ui.open_channel("#team", actor="user") - view = app.screen.query_one(CommsChatView) - await refresh_chat(view) - await ui.step() - assert view.display and view.message_history.source.comms.root == second - assert any("NEW-WIRE-ONLY" in str(message) for message in view.message_history.messages), view.message_history.messages - assert all("OLD-WIRE-ONLY" not in str(message) for message in view.message_history.messages) - - # A proven pre-append rejection remains editable; an UNKNOWN or an interrupted - # committed send does not. - with patch.object(view.message_history.source.comms.messaging, "send_user_message", - side_effect=ValueError("pre-append rejection")) as rejected: - await view.submit_input(input_events.UserInputSubmitted("EDITABLE-REJECTION")) - assert rejected.call_count == 1 - assert view.prompt.text == "EDITABLE-REJECTION" - assert not view.prompt.prompt_text_area.disabled - assert not view._send_block_reason - view.prompt.text = "" - - # A send Core refuses to retry retains text for inspection and disables compose. - error = HumanClaimBlockedError("prior reservation cannot be retried") - with patch.object(view.message_history.source.comms.messaging, "send_user_message", - side_effect=error) as sender: - event = input_events.UserInputSubmitted("BLOCKED-NO-RETRY") - await view.submit_input(event) - assert sender.call_count == 1 - assert view.send_block_reason == "Send blocked: prior reservation cannot be retried" - assert view.prompt.text == event.body - assert view.prompt.prompt_text_area.disabled - await view.submit_input(event) - assert sender.call_count == 1 - route_path.write_text("{") - route_path.chmod(0o600) - await refresh_chat(view) - await ui.step() - assert not view.display - assert not ui.shows("NEW-WIRE-ONLY") + Comms(first).messaging.send("peer", "#team", "FIRST ROUTE STILL LIVE") + await ui.until_shows("FIRST ROUTE STILL LIVE") + assert RouteSelection.capture().root == first + assert app.coordination_access.observation.observed().service.root == first + assert not ui.shows("NEW-WIRE-ONLY") + await ui.select_tab(owner) + await ui.select_tab(mode) + assert ui.conversation is chat + assert ui.draft() == "THE FIRST ROUTE DRAFT" + await ui.until_shows("FIRST ROUTE STILL LIVE") + + LaunchRoot.startup([], ActiveRoute.published_launch) + async with workspace.app(size=(115, 38)) as ui: + app, _ = ui.textual + await ui.open_channel("#team", actor="user") + await ui.until_shows("NEW-WIRE-ONLY") + assert RouteSelection.capture().root == second + assert app.coordination_access.observation.observed().service.root == second + assert not ui.shows("OLD-WIRE-ONLY") + assert not ui.shows("FIRST ROUTE STILL LIVE") + assert ui.draft() == "" diff --git a/tests/textual/test_historical_notification_source.py b/tests/textual/test_historical_notification_source.py index 9001a2ecf..baaaf730f 100644 --- a/tests/textual/test_historical_notification_source.py +++ b/tests/textual/test_historical_notification_source.py @@ -1,62 +1,91 @@ -"""Textual-facing: a historical-sessions screen reads handling notifications from the source it selected, and drops them when its body, screen or selection is gone.""" - -import asyncio +"""Textual-facing: the saved-session viewer subscribes to its selected archive and retires that subscription on selection and close.""" from agent_comms.comms import Comms -import pytest - +from agent_comms.ui_model.observation import ReadRecordedNotifications +from textual.widgets import Select -@pytest.mark.parametrize("retirement", (None, "selection", "body", "screen", "detached")) -def test_historical_notification_keeps_selected_source_and_lifetime( - tmp_path, monkeypatch, retirement, -): - """Source callback boundary only; no mounted app.""" - from types import SimpleNamespace +from support.routes import private_root +from toad.screens.historical_sessions import HistoricalSessions, RecordedContents - from agent_comms.historical_views import HistoricalThread, HistorySource - from toad.screens.historical_sessions import HistoricalSessions - from toad.widgets.wire_message_handling import WireMessageHandling - old, live = Comms(tmp_path / "old"), Comms(tmp_path / "live") - old.registry.declare(old.registry.new_thread("sender", frozenset(), str(tmp_path), created_at=10.0)) - old.registry.declare(old.registry.new_thread("agent", frozenset(), str(tmp_path), created_at=12.0)) - live.registry.declare(live.registry.new_thread("agent", frozenset(), str(tmp_path), created_at=212.0)) - old.messaging.initialize_private_initial_protocol() - messages = (old.messaging.send("sender", "agent", "historical notification").message,) - source = live.views.attach_history(old.root) - item = HistoricalThread(source, source.provenance.require("agent")) - published, errors, selected = [], [], [] - body = SimpleNamespace( - is_attached=True, handling_references=(messages[0].reference,), - show_notifications=published.append, show_notification_error=errors.append, - ) - owner = SimpleNamespace(comms=live, _selection_generation=1, is_attached=True, - query_one=lambda *_: object()) - monkeypatch.setattr(WireMessageHandling, "within", lambda *_: (body,)) - unwrapped = HistorySource.notification_references +async def test_historical_notifications_follow_the_mounted_selection_and_close(workspace): + live = workspace.comms + workspace.thread("peer", "team") + workspace.thread("sender", "team") + live.messaging.send("sender", "peer", "THE LIVE CONVERSATION") + archives = [] + for label in ("FIRST", "SECOND"): + root, _ = private_root( + workspace.roots.attempt / label.lower(), workspace.project, "archive seed" + ) + archive = Comms(root) + message = archive.messaging.send("owner", "peer", f"ARCHIVED {label}").message + source = live.views.attach_history(root) + request = ReadRecordedNotifications( + str(live.root), (message.reference,), source=source.key + ) + expected = request.read(live) + assert expected, "The archived message must retain its notification result" + archives.append((source, message, expected)) - def read(captured, archive, references): - selected.append(captured.key) - if retirement == "detached": - live.bus.history.path.write_text("[]") - result = unwrapped(captured, archive, references) - if retirement == "selection": - owner._selection_generation += 1 - elif retirement == "body": - body.is_attached = False - elif retirement == "screen": - owner.is_attached = False - return result + async with workspace.app(size=(120, 40)) as ui: + app, _ = ui.textual + await ui.open_direct("peer", actor=workspace.viewer) + chat = ui.conversation + await ui.until_shows("THE LIVE CONVERSATION") + await ui.press("f8") + await ui.until( + lambda: isinstance(app.screen, HistoricalSessions), + why="the saved-session viewer", + ) + viewer = app.screen + access = app.coordination_access + selector = viewer.query_one("#saved-identity", Select) + previous = None + for source, message, expected in archives: + selector.value = next( + index for index, item in enumerate(viewer.threads) + if item.source.key == source.key and item.thread.name == "peer" + ) + await ui.until_shows(message.body) + await ui.until( + lambda: any( + isinstance(read, ReadRecordedNotifications) + and read.source == source.key + and message.reference in read.references + for read in access.notification_interest.reads + ), + why="the visible archive's notification subscription", + ) + request = next( + read for read in access.notification_interest.reads + if isinstance(read, ReadRecordedNotifications) + and read.source == source.key and message.reference in read.references + ) + await ui.until( + lambda: expected.keys() + <= access.notification_outcomes.get(request, {}).keys(), + why="the selected archive's notification answer", + ) + assert all( + access.notification_outcomes[request][reference] == state + for reference, state in expected.items() + ) + if previous is not None: + assert not previous.is_attached + assert previous not in access.notification_views + previous = viewer.query_one("#saved-content", RecordedContents) - monkeypatch.setattr(HistorySource, "notification_references", read) - asyncio.run(HistoricalSessions.publish_handling(owner, item, 1)) - assert selected == [source.key] - if retirement is None: - assert not errors and len(published) == 1 - notification, = published[0][messages[0].seq, messages[0].message_id] - assert notification.state == "Pending" and not notification.busy - elif retirement == "detached": - assert not published and len(errors) == 1 - assert "detached" in str(errors[0]) - else: - assert not published and not errors + await ui.press("escape") + await ui.until(lambda: app.screen is not viewer, why="the saved viewer to close") + await ui.until( + lambda: not any( + isinstance(read, ReadRecordedNotifications) + for read in access.notification_interest.reads + ), + why="the closed viewer to retire its archive subscriptions", + ) + assert previous not in access.notification_views + assert ui.conversation is chat + await ui.until_shows("THE LIVE CONVERSATION") + assert not ui.shows("ARCHIVED FIRST") and not ui.shows("ARCHIVED SECOND") From 5e71f7a3647487dc466c64e66156dea63cc0b21b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 06:43:58 -0400 Subject: [PATCH 42/43] Ask session scopes to match launches without filtering view classes --- src/toad/session_navigation.py | 2 +- src/toad/session_scope.py | 3 +++ src/toad/workspace_sessions.py | 10 ---------- tests/textual/test_session_scope.py | 4 ++-- tests/textual/test_welcome_view.py | 4 +++- 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index b7e755703..e590f6415 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -223,7 +223,7 @@ async def launch( existing = next( ( identity - for identity, origin in app.workspace_sessions.native_origins.items() + for identity, origin in app.workspace_sessions.views.items() if origin.matches_launch(agent_identity, agent_session_id) ), None, diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index c22890d5e..6ecf5fcf3 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -198,6 +198,9 @@ class SessionScope(NavigationOwner, ProjectPathOwner, ToadNode): footer_compact = False presentation: OperationalSessionPresentation + def matches_launch(self, agent_identity: str, session_id: str) -> bool: + return False + @property @abstractmethod def is_current(self) -> bool: ... diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 8389f0457..48505bda4 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -14,7 +14,6 @@ NoSessionOrigin, SessionOrigin, SessionScope, - ViewSessionOrigin, ) if TYPE_CHECKING: @@ -165,15 +164,6 @@ def __init__(self, app: "ToadApp") -> None: self.views: dict[str, SessionScope] = {} self.source: WorkspaceSource = DetachedWorkspaceSource() - @property - def native_origins(self) -> dict[str, ViewSessionOrigin]: - """Derive actual native membership from the existing mounted-view owner.""" - return { - identity: view - for identity, view in self.views.items() - if isinstance(view, ViewSessionOrigin) - } - def require(self, identity: str) -> "SessionScope": return self.views[identity] diff --git a/tests/textual/test_session_scope.py b/tests/textual/test_session_scope.py index cb2a86bef..134695a11 100644 --- a/tests/textual/test_session_scope.py +++ b/tests/textual/test_session_scope.py @@ -24,7 +24,7 @@ async def test_real_conversation_discovers_screen_free_scope(workspace): await ui.write_draft("session scope journey") await ui.press("enter") await ui.until_shows("PEER_ANSWER session scope journey") - assert app.workspace_sessions.native_origins[first] is view + assert app.workspace_sessions.views[first] is view assert view.matches_launch( definition.identity, ui.conversation.agent_binding.session_id ) @@ -45,6 +45,6 @@ async def test_real_conversation_discovers_screen_free_scope(workspace): assert ui.conversation.query_ancestor(SessionScope) is view await ui.until_shows("PEER_ANSWER session scope journey") await ui.close_tab(second) - assert second not in app.workspace_sessions.native_origins + assert second not in app.workspace_sessions.views with pytest.raises(RelationViolationError): app.workspace_sessions.origin(second).resolve_thread() diff --git a/tests/textual/test_welcome_view.py b/tests/textual/test_welcome_view.py index 025d74301..2007051d7 100644 --- a/tests/textual/test_welcome_view.py +++ b/tests/textual/test_welcome_view.py @@ -23,7 +23,9 @@ async def test_starting_opens_no_tab_and_starts_no_agent(workspace): await ui.until_shows(THREADS) assert ui.tabs() == () app, _ = ui.textual - assert app.workspace_sessions.native_origins == {} + assert not app.workspace_sessions.source.view.matches_launch( + peer("echo").identity, "unopened-session" + ) assert ui.shows(HINT) and ui.shows(CHANNELS) assert not workspace.child_processes( "echo.py" From 733005d602fee7a17559d278afc1340156283e22 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 07:06:01 -0400 Subject: [PATCH 43/43] Annotate P401 session scope parameters and fail loud on agent read errors Every parameter the PR added in src/toad now carries the type its callers pass: SessionScope's abstract methods and both overrides (CoordinationAccess, Conversation, TranscriptCursor | None, TranscriptPublicationState, UserInputSubmitted, type[DmConversation], the presented callback), with **bounds spelled out as the four keywords acp/transcript_reader.py passes. rows_in takes SidebarGroup[CommsRow]; workspace visible_target/target_for_mode take root: Path | None and identity: str. NoTarget's menu_context/show_menu overrides are deleted: NoTarget.rows_in returns (), so no row ever carries it, and both are reached only through a row's target. cli.get_agent_binding no longer turns AgentReadError into NoAgent(). Its only caller is `toad run -a NAME`; a user who asked for an agent whose definitions cannot be read now sees the read error instead of a session silently launched without the agent. Co-Authored-By: Claude Opus 5.5 --- src/toad/cli.py | 7 +- src/toad/navigation_target.py | 13 ++-- src/toad/session_scope.py | 127 +++++++++++++++++++++++++++------ src/toad/workspace_sessions.py | 11 +-- 4 files changed, 118 insertions(+), 40 deletions(-) diff --git a/src/toad/cli.py b/src/toad/cli.py index 6ebcd4e36..b05049d11 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -58,12 +58,9 @@ async def get_agent_binding(launch_agent: str) -> AgentBinding: return NoAgent() launch_agent = launch_agent.lower() - from toad.agents import read_agents, AgentReadError + from toad.agents import read_agents - try: - agents = await read_agents() - except AgentReadError: - return NoAgent() + agents = await read_agents() for agent_data in agents.values(): if ( diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index b8987ab00..b98132fe9 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -23,6 +23,9 @@ if TYPE_CHECKING: from agent_comms.display_references import CurrentDisplayReferences + from toad.widgets.comms_sidebar import CommsRow + from toad.widgets.sidebar_tree import SidebarGroup + @dataclass(frozen=True) class NavigationTarget(DeclaredFamily, affix="Target"): name: str @@ -52,7 +55,7 @@ def matches(self, target: NavigationTarget) -> bool: """Different opening behaviors may display the same sidebar address.""" return self.name == target.name - def rows_in(self, group): + def rows_in(self, group: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: return group.rows_for_target(self.name) @abstractmethod @@ -64,19 +67,13 @@ async def open(self, context: NavigationContext) -> str: class NoTarget(NavigationTarget): name: str = field(default="", init=False) - def menu_context(self, sidebar, **kwargs): - pass - - def show_menu(self, sidebar, offset, **kwargs) -> None: - pass - def selected(self, owner: NavigationOwner) -> None: pass def matches(self, target: NavigationTarget) -> bool: return False - def rows_in(self, group): + def rows_in(self, group: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: return () async def open(self, context: NavigationContext) -> str: diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 6ecf5fcf3..fc8cc092c 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import partial from pathlib import Path @@ -13,7 +13,11 @@ from agent_comms.errors import RelationViolationError from agent_comms.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation -from agent_comms.ui_model.observation import ReadTranscript, ReadTranscriptUpdate +from agent_comms.ui_model.observation import ( + ReadTranscript, + ReadTranscriptUpdate, + TranscriptPublicationState, +) from toad.app_node import ToadNode from toad.constants import WELCOME @@ -25,10 +29,15 @@ from toad.status_thread import NoStatusThread, StatusThread if TYPE_CHECKING: + from agent_comms.messages import Message + from agent_comms.transcripts import TranscriptCursor from textual.command import Provider from textual.dom import DOMNode from toad.agent_schema import AgentDefinition + from toad.comms_root import CoordinationAccess + from toad.conversation_kind import DmConversation + from toad.core.input_events import UserInputSubmitted from toad.navigation_target import NavigationTarget from toad.session_presentation import OperationalSessionPresentation from toad.thread_identity import SessionIdentity @@ -48,26 +57,47 @@ def original_threads( ) -> tuple[tuple[str, ThreadIncarnation], ...]: ... @abstractmethod - def matches_session(self, access, session_id: str) -> bool: ... + def matches_session(self, access: CoordinationAccess, session_id: str) -> bool: ... @abstractmethod - def status_thread(self, access) -> StatusThread: ... + def status_thread(self, access: CoordinationAccess) -> StatusThread: ... @abstractmethod - def observe(self, view, access, resume) -> ResourceRelease: ... + def observe( + self, + view: Conversation, + access: CoordinationAccess, + resume: Callable[[ThreadRowsChanged], bool | None], + ) -> ResourceRelease: ... @abstractmethod - def snapshot_request(self, **bounds) -> ReadTranscript: ... + def snapshot_request( + self, + *, + before: TranscriptCursor | None = None, + after: TranscriptCursor | None = None, + through: TranscriptCursor | None = None, + historical_source: str | None = None, + ) -> ReadTranscript: ... @abstractmethod - def update_request(self, previous, **bounds) -> ReadTranscriptUpdate: ... + def update_request( + self, + previous: TranscriptPublicationState, + *, + after: TranscriptCursor | None = None, + ) -> ReadTranscriptUpdate: ... @abstractmethod def mailbox_target(self) -> ThreadTarget: ... @abstractmethod async def submit_mailbox( - self, view, event, conversation_type, presented + self, + view: Conversation, + event: UserInputSubmitted, + conversation_type: type[DmConversation], + presented: Callable[[Message | None], Awaitable[None]], ) -> None: ... @@ -82,17 +112,22 @@ def original_threads( ) -> tuple[tuple[str, ThreadIncarnation], ...]: return ((self.root, identity.thread_incarnation()),) - def matches_session(self, access, session_id: str) -> bool: + def matches_session(self, access: CoordinationAccess, session_id: str) -> bool: service = access.require(RouteSelection.capture(self.root)).service return ( service.registry.canonical_name(session_id) == service.registry.require(self.name).name ) - def status_thread(self, access) -> StatusThread: + def status_thread(self, access: CoordinationAccess) -> StatusThread: return StatusThread.for_root(Path(self.root), self.name, access) - def observe(self, view, access, resume) -> ResourceRelease: + def observe( + self, + view: Conversation, + access: CoordinationAccess, + resume: Callable[[ThreadRowsChanged], bool | None], + ) -> ResourceRelease: release = ResourceRelease() release.callback( view.retire_core, @@ -107,16 +142,41 @@ def observe(self, view, access, resume) -> ResourceRelease: ) return release - def snapshot_request(self, **bounds) -> ReadTranscript: - return ReadTranscript(self.root, self.name, **bounds) + def snapshot_request( + self, + *, + before: TranscriptCursor | None = None, + after: TranscriptCursor | None = None, + through: TranscriptCursor | None = None, + historical_source: str | None = None, + ) -> ReadTranscript: + return ReadTranscript( + self.root, + self.name, + before=before, + after=after, + through=through, + historical_source=historical_source, + ) - def update_request(self, previous, **bounds) -> ReadTranscriptUpdate: - return ReadTranscriptUpdate(self.root, self.name, previous=previous, **bounds) + def update_request( + self, + previous: TranscriptPublicationState, + *, + after: TranscriptCursor | None = None, + ) -> ReadTranscriptUpdate: + return ReadTranscriptUpdate(self.root, self.name, previous=previous, after=after) def mailbox_target(self) -> ThreadTarget: return ThreadTarget(self.name) - async def submit_mailbox(self, view, event, conversation_type, presented) -> None: + async def submit_mailbox( + self, + view: Conversation, + event: UserInputSubmitted, + conversation_type: type[DmConversation], + presented: Callable[[Message | None], Awaitable[None]], + ) -> None: await view.submit_comms_message( self.root, conversation_type( @@ -137,28 +197,51 @@ def original_threads( ) -> tuple[tuple[str, ThreadIncarnation], ...]: return () - def matches_session(self, access, session_id: str) -> bool: + def matches_session(self, access: CoordinationAccess, session_id: str) -> bool: return False - def status_thread(self, access) -> StatusThread: + def status_thread(self, access: CoordinationAccess) -> StatusThread: return NoStatusThread() - def observe(self, view, access, resume) -> ResourceRelease: + def observe( + self, + view: Conversation, + access: CoordinationAccess, + resume: Callable[[ThreadRowsChanged], bool | None], + ) -> ResourceRelease: return ResourceRelease() def unavailable(self) -> Never: raise ValueError("Transcript paging requires a bound thread") - def snapshot_request(self, **bounds) -> Never: + def snapshot_request( + self, + *, + before: TranscriptCursor | None = None, + after: TranscriptCursor | None = None, + through: TranscriptCursor | None = None, + historical_source: str | None = None, + ) -> Never: self.unavailable() - def update_request(self, previous, **bounds) -> Never: + def update_request( + self, + previous: TranscriptPublicationState, + *, + after: TranscriptCursor | None = None, + ) -> Never: self.unavailable() def mailbox_target(self) -> Never: self.unavailable() - async def submit_mailbox(self, view, event, conversation_type, presented) -> None: + async def submit_mailbox( + self, + view: Conversation, + event: UserInputSubmitted, + conversation_type: type[DmConversation], + presented: Callable[[Message | None], Awaitable[None]], + ) -> None: view.delivery_refused("CLI transcript is not ready", event.body) diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 48505bda4..5bfdd47bd 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -2,6 +2,7 @@ from abc import abstractmethod from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily @@ -54,7 +55,7 @@ def coordination_root(self): ... def focus_sidebar_return(self) -> None: ... @abstractmethod - def visible_target(self, sessions: WorkspaceSessions, root) -> NavigationTarget: ... + def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: ... @abstractmethod def sidebars(self): @@ -84,7 +85,7 @@ def coordination_root(self): def focus_sidebar_return(self) -> None: """A detached workspace has no return target.""" - def visible_target(self, sessions: WorkspaceSessions, root) -> NavigationTarget: + def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: return NoTarget() def sidebars(self): @@ -124,7 +125,7 @@ def coordination_root(self): def focus_sidebar_return(self) -> None: self.view.focus_sidebar_return() - def visible_target(self, sessions: WorkspaceSessions, root) -> NavigationTarget: + def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: return ( sessions.target_for_mode(self.identity, root) if sessions.owns(self.view) @@ -220,11 +221,11 @@ async def retire(self) -> None: def owns(self, view: "SessionScope") -> bool: return self.source.current(view) - def visible_target(self, root) -> NavigationTarget: + def visible_target(self, root: Path | None) -> NavigationTarget: """The selected logical destination, including while a modal covers its frame.""" return self.source.visible_target(self, root) - def target_for_mode(self, identity, root) -> NavigationTarget: + def target_for_mode(self, identity: str, root: Path | None) -> NavigationTarget: """Resolve a transition's destination from its original workspace view.""" view = self.views.get(identity) return (