diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index daa613ae4..d4a99c0ac 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, replace from collections.abc import Callable from agent_comms.route_selection import RouteSelection -from agent_comms.thread_presentation import SessionDisconnected +from agent_comms.thread_presentation import SessionDisconnected, ThreadPresentation import toad from toad import constants from acp.exceptions import RequestError @@ -317,14 +317,14 @@ async def run(self) -> None: 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 diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 116c38905..f4846b145 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 ( @@ -27,7 +28,7 @@ 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 ThreadPresentation +from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation from toad.acp.client_session import ClientSessionRequest from toad.acp.context_measurement import ContextMeasurement @@ -54,7 +55,9 @@ async def consume_metadata(self, metadata) -> None: 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) @@ -64,7 +67,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) @@ -73,7 +76,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: @@ -87,7 +94,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: @@ -114,15 +125,15 @@ 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) @handles(CoordinationChangedUpdate) def coordination_changed(self, update: CoordinationChangedUpdate) -> None: - from pathlib import Path - agent = self.agent root = agent.controller.transcripts.access.launch_root.path if Path(update.wire_root).resolve() != root.resolve(): @@ -131,7 +142,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 +164,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) @@ -197,11 +214,16 @@ def __init__(self, agent, session_id, *, turn_token) -> None: 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(NoThreadPresentation) + def missing_thread_presentation(self, presentation: NoThreadPresentation) -> object: + return self.DISCARD @handles(ThreadPresentation) - def thread_presentation(self, presentation) -> None: + def thread_presentation(self, presentation: ThreadPresentation) -> None: identity = presentation.read_identity if identity is None: return # An unavailable source supplies no turn settlement. @@ -211,9 +233,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)) @@ -234,7 +257,7 @@ class ChangedAttachmentRead(ThreadReadOutcome): @dataclass(frozen=True) class SettledThreadRead(ThreadReadOutcome): - presentation: ThreadPresentation | None + presentation: ThreadPresentation def present(self, consumer) -> None: consumer(self.presentation) @@ -243,7 +266,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: @@ -268,18 +291,20 @@ 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, self.coordination.thread + self.coordination.wire_root, + self.coordination.thread, ): 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: - 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 824e8943e..aa5f6fac8 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -13,14 +13,22 @@ from __future__ import annotations import asyncio +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, ReadTranscript, ReadTranscriptUpdate, RefreshTranscript, + AwaitedRead, + RefreshTranscript, + StoredRead, TranscriptPublicationState, + UnreadableHistory, ) +from toad.owner_preparation import read_thread_presentation +from toad.session_scope import TranscriptScope + class CoordinationTranscriptReader: """The application's route-owned reads, shared by agents from construction.""" @@ -29,39 +37,75 @@ 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) - - 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 + lambda: self.access.require(RouteSelection.capture(root)).service + ) + async def thread_presentation( + self, root: str, thread: str + ) -> StoredRead[ThreadPresentation]: + """The stored-read owner classifies failures inside the read worker.""" reader = await self.service(root) - return await asyncio.to_thread(read_thread_presentation, reader, thread) + return await asyncio.to_thread( + UnreadableHistory.outcome, + partial(read_thread_presentation, reader, thread), + ) - 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, root, thread, *, before=None, after=None, through=None, - historical_source: str | None = None): - snapshot = await self.snapshot(root, thread, 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, root, thread, *, 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)) + 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, 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/agent.py b/src/toad/agent.py index 1d98b2412..d42114491 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -1,16 +1,17 @@ -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 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: @@ -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()) @@ -45,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) @@ -56,8 +59,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) @@ -71,6 +77,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) @@ -124,7 +131,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/agent_binding.py b/src/toad/agent_binding.py index ede6da689..19af00e5c 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 @@ -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/app.py b/src/toad/app.py index 8ed7b87c2..c718d84a6 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 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, project_dir: str | None = None, mode: str | None = None, agent_session_id: str | None = None, @@ -156,10 +156,9 @@ 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. """ from toad.render_processes import RenderProcessPool from toad.render_backend import PreparedRenderer @@ -171,7 +170,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/application_actions.py b/src/toad/application_actions.py index 44821d3fa..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,9 +7,15 @@ 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 from agent_comms.cli_commands import NewThreadCliCommand from agent_comms.channel_targets import ProjectTarget @@ -29,17 +36,33 @@ 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 - app.run_worker(partial(self.collect, app, mode, project), group="new-session", exclusive=True) + 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): 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() @@ -61,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() @@ -79,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) @@ -167,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) @@ -228,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) @@ -256,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 e9408965f..26de6263d 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) @@ -343,6 +358,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 @@ -350,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/cli.py b/src/toad/cli.py index 237929e12..b05049d11 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,25 +53,23 @@ 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 + from toad.agents import read_agents - try: - agents = await read_agents() - except AgentReadError: - agents = {} + agents = await read_agents() 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) - return agents.get(launch_agent) + return NoAgent() class DefaultCommandGroup(click.Group): @@ -133,18 +132,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 +197,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) @@ -282,7 +279,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/comms_root.py b/src/toad/comms_root.py index 99b46cf52..c9f408944 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -580,4 +580,3 @@ def write(self, selected: RouteSelection, operation: Callable[..., T], *args: ob # 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_comms_handlers.py b/src/toad/conversation_comms_handlers.py index 2d6e2374e..b7555865e 100644 --- a/src/toad/conversation_comms_handlers.py +++ b/src/toad/conversation_comms_handlers.py @@ -106,14 +106,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.prompt.restore_draft(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 2acb953dd..affa770d5 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -308,6 +308,6 @@ 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): self.publish_pending() diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index f230ee002..7e5cfa7e1 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -2,6 +2,7 @@ import asyncio +from abc import abstractmethod from dataclasses import dataclass from pathlib import Path @@ -13,6 +14,15 @@ RouteChangedError, ) 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: @@ -55,8 +65,18 @@ def reason(self) -> str: return f"Send blocked: {self.description}" -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) + feedback = getters.query_one(Flash) + status: var[str | Content | RichText] = var("") + + @abstractmethod + def draws_user_message(self, message: Message) -> bool: ... + + async def composer_read_only(self) -> bool: + return False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -97,7 +117,7 @@ async def submit_comms_message(self, root, conversation, body, current, present) if not body.strip(): return if self.send_block_reason: - self.flash(self.send_block_reason, style="error") + self.feedback.flash(self.send_block_reason, style="error") return try: comms = await self.app.coordination_access.transcripts.service(root) @@ -105,7 +125,7 @@ async def submit_comms_message(self, root, conversation, body, current, present) await self.refresh_composer(read_only) if read_only: self.prompt.restore_draft(body) - self.flash(self.status, style="error") + self.feedback.flash(self.status, style="error") return self._enter_send_state(SendPending()) route_current = await self.app.preparation.run_thread(root_is_current, root) @@ -147,9 +167,9 @@ def settle_user_message(self) -> None: def delivery_blocked(self, description: str, body: str) -> None: self.prompt.restore_draft(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.prompt.restore_draft(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/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_preparation.py b/src/toad/navigation_preparation.py index c384f2635..2d5346410 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,15 +8,16 @@ 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.owner_preparation import ExistingOwnerPlan from toad.session_tracker import CommsViewKey ResultT = TypeVar("ResultT") @@ -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,12 @@ 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_binding, + ) self.bind(screen) return screen @@ -94,7 +101,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() @@ -114,7 +121,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 +148,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 +185,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 +213,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 +254,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 ed4311bac..b98132fe9 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -4,7 +4,6 @@ from abc import abstractmethod from dataclasses import dataclass, field -from pathlib import Path from typing import TYPE_CHECKING, ClassVar from agent_comms.channel_targets import ChannelSelection, CommandTarget @@ -13,6 +12,7 @@ from agent_comms.declared_family import DeclaredFamily from toad.constants import ALL_COMMS_TARGET +from toad.navigation_context import NavigationContext, NavigationOwner from toad.conversation_kind import ( ChannelConversation, ConversationKind, @@ -23,31 +23,8 @@ if TYPE_CHECKING: from agent_comms.display_references import CurrentDisplayReferences - 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.widgets.comms_sidebar import CommsRow + from toad.widgets.sidebar_tree import SidebarGroup @dataclass(frozen=True) class NavigationTarget(DeclaredFamily, affix="Target"): @@ -74,11 +51,35 @@ 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: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: + 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 selected(self, owner: NavigationOwner) -> None: + pass + + def matches(self, target: NavigationTarget) -> bool: + return False + + def rows_in(self, group: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: + 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 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/project_path_owner.py b/src/toad/project_path_owner.py index 49cca3669..2f419c623 100644 --- a/src/toad/project_path_owner.py +++ b/src/toad/project_path_owner.py @@ -1,21 +1,32 @@ """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: @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): @@ -23,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 de750a74c..84c5e50bb 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 @@ -23,14 +22,14 @@ from toad.core.input_events import SelectTarget 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.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 -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" @@ -69,6 +68,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 @@ -88,9 +88,12 @@ def origin(self): return self.app.workspace_sessions.origin(self.owner_mode) @property - def navigation_target_name(self) -> str: - references = self.app.coordination_access.observation.observed().sidebar.observation.require_snapshot().references - return self.conversation.destination(references).name + def navigation_target(self) -> NavigationTarget: + return HistoryTarget.for_conversation( + self.conversation, + self.app.coordination_access.observation.observed() + .sidebar.observation.require_snapshot().references, + ) def rebind_identity(self, identity: str) -> None: with self.changing_identity(): @@ -119,7 +122,9 @@ def compose(self) -> ComposeResult: content = ( (self.create_chat(),) if self._content_loaded - else (Static(f"Opening {self.conversation.initial_label}…", id="comms-opening"),) + else ( + Static(f"Opening {self.conversation.initial_label}…", id="comms-opening"), + ) ) yield containers.Center( ThreadSidebar( @@ -128,8 +133,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"), @@ -138,32 +147,45 @@ 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.coordination_access.observation.observed().sidebar.observation.require_snapshot().references)) + self.app.push_screen( + self.conversation.historical_sessions( + partial(HistoricalSessions, comms, threads), + self.app.coordination_access.observation.observed() + .sidebar.observation.require_snapshot().references, + ) + ) def on_mount(self) -> None: if not self._content_loaded: @@ -234,11 +256,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: @@ -247,7 +269,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: @@ -263,10 +290,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 c83d77df3..6a263773a 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,28 +24,35 @@ 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.navigation_target import NavigationContext, NavigationOwner -from toad.screens.session_view import SessionOrigin, SessionView -from toad.widgets.message_notifications import NothingToRead, PaintedRead, ReadWork +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.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_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.session_scope import AgentBinding, NoAgent, ViewSessionOrigin +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 -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 +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): @@ -78,16 +78,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"): @@ -189,7 +197,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(SessionOrigin, SessionView, NavigationOwner, can_focus=False): +class MainScreen(ViewSessionOrigin, SessionView, can_focus=False): footer_compact = True AUTO_FOCUS = "Conversation Prompt TextArea" @@ -222,6 +230,7 @@ class MainScreen(SessionOrigin, SessionView, NavigationOwner, 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) @@ -233,16 +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 = agent - self._agent_session_id = 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() @@ -271,14 +278,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") @@ -286,9 +297,10 @@ async def mark(): return ReadWork(mark()) @classmethod - def from_thread(cls, project: Path, root: str, target, - agent: AgentDefinition | None = None) -> "MainScreen": - screen = cls(project, agent, agent_session_id=target.name) + def from_thread( + cls, project: Path, root: str, target, binding: AgentBinding = NoAgent() + ) -> "MainScreen": + screen = cls(project, binding.for_session(target.name)) screen.bind_thread(target, root) return screen @@ -298,13 +310,17 @@ 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 or app.agent_data, - agent_session_id=request.target) + screen = cls( + request.project, + origin.source.agent_for_opening(app.agent_binding).for_session(request.target), + ) screen.content = NavigatedOpeningContent() return screen @@ -335,7 +351,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)) @@ -345,10 +363,20 @@ def retained_native_presentations(self): def session_disposition(self) -> SessionDisposition: return self.presentation.operational_binding.session_disposition - @property - def agent_session_id(self) -> str | None: - """The saved ACP session this screen owns.""" - return self._agent_session_id + session_details: SessionDetails + """The registered entry supplies its existing details before mounting this view.""" + + def make_conversation(self) -> Conversation: + return self._agent_binding.make_conversation( + partial( + Conversation, + self.project_path, + session_pk=self._session_pk, + session_title=self.session_details.initial_title, + initial_prompt=self._initial_prompt, + transcript=self.presentation.transcript, + ) + ) def watch_title(self, title: str) -> None: self.app.terminal_attention.update() @@ -418,7 +446,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) @@ -431,13 +461,15 @@ 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: 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 @@ -459,8 +491,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)) @@ -472,11 +506,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: @@ -533,14 +571,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) @@ -560,12 +599,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 713f106e1..fd8bf96e9 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -1,56 +1,28 @@ """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 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.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 +from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope +from toad.navigation_target import NavigationTarget, NoTarget +from toad.widgets.message_notifications import ( + NothingToRead, + PaintedRead, + VisibleReadMarking, +) - 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("") 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() @@ -60,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: @@ -75,13 +49,18 @@ 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)) + + @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 + source = self.coordination_root return source is None or Path(source).expanduser().resolve() == root @@ -97,20 +76,14 @@ 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") - - def sidebar_focus_target(self) -> Widget | None: - return None + 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.""" 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 @@ -172,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/store.py b/src/toad/screens/store.py index 962f4c86c..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 @@ -595,6 +594,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/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index e0dc0d68c..fac8d8414 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -20,12 +20,20 @@ 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 SessionOrigin, SessionView +from toad.screens.session_view import SessionView +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: @@ -35,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; } @@ -85,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: @@ -106,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() @@ -121,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) @@ -166,7 +185,7 @@ async def present_selected(self, event: CoreEventMessage) -> None: self.present() -class WelcomeScreen(SessionOrigin, SessionView, NavigationOwner, can_focus=False): +class WelcomeScreen(ProjectSessionOrigin, SessionView, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" BINDINGS = [ @@ -181,35 +200,46 @@ class WelcomeScreen(SessionOrigin, SessionView, NavigationOwner, 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__() 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 "" + agent = self.app.agent_binding.welcome_prefix 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 4b303742e..157176ede 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -1,18 +1,22 @@ """The one native workspace frame; session surfaces never own compositors.""" +from __future__ import annotations + +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.app_node import ToadNode from toad.widget_view import WidgetView -from agent_comms.declared_family import DeclaredFamily -from abc import abstractmethod @dataclass(frozen=True) class WorkspaceLayoutSnapshot: @@ -28,14 +32,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"): @@ -56,15 +67,20 @@ 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): +class WorkspaceScreen(WidgetView, ToadNode, Screen): layer_order = (DEFAULT_LAYER, "comms-sidebar", "_tooltips", "_toastrack") 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 @@ -85,7 +101,8 @@ 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 with Horizontal(id="workspace-body").set_styles(layout=chrome): @@ -100,23 +117,31 @@ 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": 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: @@ -132,9 +157,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 @@ -145,8 +172,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() @@ -175,9 +205,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(): @@ -185,8 +220,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) @@ -232,7 +272,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 @@ -261,7 +302,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 2fc24996a..07579523f 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 @@ -13,20 +14,25 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.display_references import CurrentDisplayReferences, ReferenceContext from agent_comms.mro_dispatch import handles -from toad.core.projection import MroProjection from agent_comms.route_selection import RouteSelection -from toad.owner_preparation import ExistingOwnerPlan from agent_comms.thread_identity import ThreadIncarnation - from agent_comms.ui_model.sidebar import ThreadRowModel -from toad.session_tracker import CommsViewKey, OpenTab, NamedTab, ThreadTab, SessionDetails +from toad.core.projection import MroProjection +from toad.owner_preparation import ExistingOwnerPlan +from toad.session_scope import SessionScope +from toad.session_tracker import ( + CommsViewKey, + NamedTab, + OpenTab, + SessionDetails, + ThreadTab, +) 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 + from toad.screens.main import MainScreen + from toad.session_navigation import SessionNavigation class SessionEntry(DeclaredFamily, affix="Entry"): @@ -38,7 +44,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() @@ -53,23 +59,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 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 - 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], ...]: @@ -86,16 +95,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 @@ -105,12 +122,14 @@ 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() - self.details.bind_initial_identity(source._agent_session_id) + source.session_details = self.details + source.thread_identity.bind_initial_identity(self.details) return source @property @@ -123,7 +142,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) @@ -133,10 +155,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)) @@ -146,19 +176,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") @@ -168,20 +185,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) @@ -189,8 +218,15 @@ def forget(self, sessions: SessionNavigation) -> None: class HistorySessionEntry(SessionEntry, MroProjection): - 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 @@ -207,11 +243,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: @@ -236,24 +277,39 @@ def preparing_tab(self, references, observed) -> OpenTab: @handles(CurrentDisplayReferences) def referenced_tab(self, references, observed) -> OpenTab: conversation = self.key.conversation - return NamedTab(self.mode, conversation.label(references), - conversation.unread(observed.sidebar.observation)) + return NamedTab( + self.mode, + conversation.label(references), + conversation.unread(observed.sidebar.observation), + ) def depends_on(self, mode: str) -> bool: return super().depends_on(mode) or self.key.owner_mode == mode 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) @@ -270,14 +326,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 @@ -297,7 +357,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) @@ -306,6 +370,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 61bf2e7b7..e590f6415 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -1,29 +1,41 @@ """Opening and closing sessions use the existing workspace's declared factories.""" + from __future__ import annotations -from agent_comms.errors import RouteChangedError import asyncio -from toad.core import events as core_events -from toad.core.events import CoreEventStream - from collections.abc import Callable +from functools import partial from itertools import count from pathlib import Path from typing import TYPE_CHECKING, cast - +from agent_comms.errors import ( + RelationViolationError, + RouteChangedError, + UnknownChannelError, + UnregisteredThreadError, +) from agent_comms.presentation import NoRevision, ReadState from agent_comms.revision_views import RegistrationsView -from agent_comms.errors import UnknownChannelError, UnregisteredThreadError from agent_comms.route_selection import 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, NativeSessionEntry, - PreviewSessionEntry, SessionEntry) +from toad.session_entry import ( + HistorySessionEntry, + NativeSessionEntry, + 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.navigation_target import HistoryRouted from toad.screens.main import MainScreen @@ -48,18 +60,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: @@ -76,17 +95,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) @@ -106,12 +132,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) @@ -127,9 +162,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) @@ -146,19 +192,45 @@ 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 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 + for identity, origin in app.workspace_sessions.views.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) @@ -172,33 +244,47 @@ async def launch(self, agent_identity: str, *, agent_session_id: str | None = No 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((entry for entry in self.members - if entry.accepts_launch(self, agent_identity, agent_session_id)), None) - if existing is not None: - await app.select_session(existing.mode) - 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, - route: HistoryRouted) -> str: + await self.new( + lambda: MainScreen( + project, + make_binding(agent), + 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, + route: HistoryRouted, + ) -> str: from toad.thread_navigation import ThreadOrigin app = self.app - source = app.workspace_sessions.origin(owner_mode) - if source is None: - app.show_error("The owning agent tab was closed", "Comms target unavailable") + 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) 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, route, source.coordination_root)) + prepared = await app.navigation_reader.read( + CommsNavigationRequest( + comms, owner_mode, me, route, source.coordination_root + ) + ) except (UnknownChannelError, UnregisteredThreadError, RouteChangedError) as error: app.show_error(error, "Comms target unavailable") return app.selected_mode @@ -214,8 +300,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) @@ -226,7 +318,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) @@ -260,12 +354,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}) @@ -292,9 +394,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): @@ -317,8 +423,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() @@ -349,9 +457,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_observation.py b/src/toad/session_observation.py index f544914bb..087ec418a 100644 --- a/src/toad/session_observation.py +++ b/src/toad/session_observation.py @@ -6,7 +6,7 @@ from agent_comms.goal_presentation import GoalSnapshot from agent_comms.ui_model.delivery import InputDelivery from agent_comms.ui_model.goal import GoalUnavailable -from toad.agent_binding import NoAgentBound +from toad.agent_binding import AgentConnection, NoAgentConnection class SessionObservation(ABC): @@ -14,9 +14,9 @@ class SessionObservation(ABC): def __init__(self, view): self._view = ref(view) + self.agent: AgentConnection = NoAgentConnection() self.revision = 0 self.task = None - self.agent = NoAgentBound() @property def view(self): @@ -58,7 +58,7 @@ async def close(self): self.task.cancel() await asyncio.gather(self.task, return_exceptions=True) self.task = None - self.agent = NoAgentBound() + self.agent = NoAgentConnection() @staticmethod async def close_all(*observations: "SessionObservation") -> None: diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index d78230c5c..72209b1dd 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -14,12 +14,12 @@ 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 from toad.owner_preparation import ExistingOwnerPlan from toad.resource_release import AsyncResourceRelease -from toad.screens.session_view import SessionView from toad.widgets.conversation import Conversation from toad.widgets.history_anchor import ParkedPosition from toad.widgets.prompt import MessagePrompt @@ -47,12 +47,15 @@ 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(), 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: @@ -71,19 +74,19 @@ class SessionSurfaceLifetime(ABC): @property def operational_binding(self): - return NoAgentBound() + return NoAgentConnection() @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): @@ -93,24 +96,27 @@ 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.""" def __init__(self) -> None: - self.agent_binding = NoAgentBound() + self.agent_binding = NoAgentConnection() self.shell = None self.load_plan = EnsuringSessionLoadPlan() 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. @@ -121,14 +127,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 @@ -153,22 +151,29 @@ 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: 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 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) @@ -179,25 +184,31 @@ 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: + 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 + binding = screen.attach_agent_definition() conversation = screen.presentation.widget if conversation is not None: - conversation._agent_data = screen._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 @@ -206,7 +217,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 @@ -215,7 +226,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: @@ -243,23 +254,19 @@ 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.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(view.query_ancestor(SessionView).thread_identity.thread_incarnation()), event.body, - lambda: view.is_attached and view.transcript.binding.thread_scope == scope, + await view.transcript.binding.thread_scope.submit_mailbox( + view, + event, + DmConversation, presented, ) @@ -277,7 +284,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: @@ -293,25 +304,32 @@ 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: + 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") -> 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 @@ -321,12 +339,15 @@ 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}") @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 @@ -339,7 +360,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: @@ -360,7 +381,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) @@ -391,7 +412,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() @@ -402,10 +423,13 @@ 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 def retire(self, screen: MainScreen) -> None: async with self._lock: if self.view is not screen or self.widget is None: return @@ -416,16 +440,18 @@ 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 - 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) - 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: @@ -444,20 +470,35 @@ 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: 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_scope.py b/src/toad/session_scope.py new file mode 100644 index 000000000..fc8cc092c --- /dev/null +++ b/src/toad/session_scope.py @@ -0,0 +1,488 @@ +"""Logical session capabilities, independent of concrete widgets and screens.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from functools import partial +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.thread_identity import ThreadIncarnation +from agent_comms.ui_model.observation import ( + ReadTranscript, + ReadTranscriptUpdate, + TranscriptPublicationState, +) + +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 +from toad.project_path_owner import ProjectPathOwner +from toad.resource_release import ResourceRelease +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 + from toad.widgets.conversation import Conversation + + +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 matches_session(self, access: CoordinationAccess, session_id: str) -> bool: ... + + @abstractmethod + def status_thread(self, access: CoordinationAccess) -> StatusThread: ... + + @abstractmethod + def observe( + self, + view: Conversation, + access: CoordinationAccess, + resume: Callable[[ThreadRowsChanged], bool | None], + ) -> ResourceRelease: ... + + @abstractmethod + 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: TranscriptPublicationState, + *, + after: TranscriptCursor | None = None, + ) -> ReadTranscriptUpdate: ... + + @abstractmethod + def mailbox_target(self) -> ThreadTarget: ... + + @abstractmethod + async def submit_mailbox( + self, + view: Conversation, + event: UserInputSubmitted, + conversation_type: type[DmConversation], + presented: Callable[[Message | None], Awaitable[None]], + ) -> 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 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: CoordinationAccess) -> StatusThread: + return StatusThread.for_root(Path(self.root), self.name, access) + + def observe( + self, + view: Conversation, + access: CoordinationAccess, + resume: Callable[[ThreadRowsChanged], bool | None], + ) -> 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, + *, + 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: 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: Conversation, + event: UserInputSubmitted, + conversation_type: type[DmConversation], + presented: Callable[[Message | None], Awaitable[None]], + ) -> None: + await view.submit_comms_message( + self.root, + conversation_type( + view.query_ancestor(SessionScope).thread_identity.thread_incarnation() + ), + 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 matches_session(self, access: CoordinationAccess, session_id: str) -> bool: + return False + + def status_thread(self, access: CoordinationAccess) -> StatusThread: + return NoStatusThread() + + 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, + *, + 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: TranscriptPublicationState, + *, + after: TranscriptCursor | None = None, + ) -> Never: + self.unavailable() + + def mailbox_target(self) -> Never: + self.unavailable() + + 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) + + +class SessionOrigin: + """An origin answers for itself, including when its view is unavailable.""" + + @abstractmethod + def transcript_scope(self) -> TranscriptScope: ... + + @abstractmethod + def resolve_thread(self) -> str: ... + + @abstractmethod + def require(self) -> ProjectSessionOrigin: ... + + +class NoSessionOrigin(SessionOrigin): + def __init__(self, view: str = "unavailable view") -> None: + self.view = view + + def transcript_scope(self) -> NoTranscript: + return NoTranscript() + + def require(self) -> Never: + raise RelationViolationError(f"View {self.view!r} has no session origin") + + def resolve_thread(self) -> Never: + self.require() + + +class SessionScope(NavigationOwner, ProjectPathOwner, ToadNode): + """The session answers read by ancestors and workspace owners.""" + + COMMANDS: ClassVar[set[type[Provider]]] = set() + AUTO_FOCUS = "" + shows_channels = True + 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: ... + + @property + @abstractmethod + def display_name(self) -> str: ... + + @property + @abstractmethod + def navigation_target(self) -> NavigationTarget: ... + + @property + @abstractmethod + def origin(self) -> SessionOrigin: ... + + @property + @abstractmethod + def thread_identity(self) -> SessionIdentity: ... + + @abstractmethod + def belongs_to_wire(self, root: str | Path) -> bool: ... + + @abstractmethod + def sidebars(self) -> tuple[DOMNode, ...]: ... + + def focus_sidebar_return(self) -> None: + """A view without a sidebar return target has nothing to focus.""" + + @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 remove(self) -> Awaitable[None]: ... + + +class ProjectSessionOrigin(SessionOrigin, SessionScope): + """A project source may navigate and resolve threads without an ACP session.""" + + @property + def origin(self) -> SessionOrigin: + return self + + def transcript_scope(self) -> TranscriptScope: + return self.thread_identity.transcript_scope() + + def require(self) -> ProjectSessionOrigin: + return self + + 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 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 welcome_prefix(self) -> str: ... + + @abstractmethod + def attached(self, app_binding: AgentBinding) -> AgentBinding: ... + + @abstractmethod + def for_session(self, session_id: str) -> AgentBinding: ... + + @abstractmethod + 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: ... + + def initial_identity(self, empty: SessionIdentity) -> SessionIdentity: + return empty + + +@dataclass(frozen=True) +class NoAgent(AgentBinding): + 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 + + 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 + + +@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_binding: AgentBinding) -> AgentBinding: + return self + + 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 + ) -> bool: + return self.agent.identity == agent_identity and self.matches_session( + origin, session_id + ) + + def matches_session(self, origin: ViewSessionOrigin, session_id: str) -> bool: + return origin.presentation.operational_binding.matches_session( + session_id + ) or origin.transcript_scope().matches_session( + origin.app.coordination_access, session_id + ) + + +@dataclass(frozen=True) +class AgentSession(AgentAttached): + session_id: str + + 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( + origin, session_id + ) + + +class ViewSessionOrigin(ProjectSessionOrigin): + """Only actual native-session views own agent/session launch matching.""" + + _agent_binding: 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_binding) + return self._agent_binding + + def use_mailbox(self) -> None: + self._agent_binding = NoAgent() + + def matches_launch(self, agent_identity: str, session_id: str) -> bool: + return self._agent_binding.matches_launch(self, agent_identity, session_id) diff --git a/src/toad/session_tracker.py b/src/toad/session_tracker.py index d520ab1cc..2be0f5742 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.""" @@ -79,6 +80,7 @@ class CommsViewKey: owner_mode: str conversation: ConversationKind + @dataclass(frozen=True) class ChannelViewAddress: root: str @@ -102,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) +class AuthoredSessionName(SessionName): + """The user's label stays when the thread presents a title or changes name.""" - @classmethod - def following(cls, thread_title: str) -> "SessionName": - return cls(None, thread_title) - 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) +class ThreadSessionName(SessionName): + """An unlabelled session follows its thread's presented title and identity.""" - def adopt_identity(self, previous: str, current: str) -> "SessionName | None": - """The thread behind this session is now named `current`. + @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 @@ -153,7 +147,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: ThreadSessionName("New Session") + ) """The name of the conversation: the user's choice or the thread's title.""" subtitle: str = "" """The subtitle of the conversation.""" @@ -170,25 +166,24 @@ 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.""" - 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 - class SessionTracker: """Tracks concurrent agent settings""" @@ -205,8 +200,9 @@ 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, - name=SessionName.following(title), + index=self._session_index, + mode_name=mode_name, + name=ThreadSessionName(title), ) self.sessions[mode_name] = session_meta self.events.publish(SessionChangedEvent(mode_name)) @@ -220,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)) @@ -244,14 +241,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)) @@ -263,4 +264,3 @@ def ordered_sessions(self) -> Sequence[SessionDetails]: def __iter__(self) -> Iterable[SessionDetails]: return iter(self.ordered_sessions) - 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/target_commands.py b/src/toad/target_commands.py index a9cc1343d..5dc14fa65 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -44,7 +44,7 @@ def title(self): def current(self): if not root_is_current(self.comms.root): - raise RouteChangedError('Comms route changed; reopen this view') + raise RouteChangedError(RouteChangedError.reader_sentence) return self def available_actions(self): diff --git a/src/toad/thread_identity.py b/src/toad/thread_identity.py index e11f681c5..b4b1539dd 100644 --- a/src/toad/thread_identity.py +++ b/src/toad/thread_identity.py @@ -1,13 +1,23 @@ """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 from pathlib import Path -from toad.format_path import format_path -from toad.transcript_state import UnboundTranscript, UnreadTranscript +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 @@ -35,8 +45,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() @@ -49,6 +59,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: @@ -66,9 +77,14 @@ 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(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.""" @@ -90,6 +106,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()}" @@ -97,6 +116,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: @@ -137,8 +157,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() @@ -148,6 +168,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 dcf771d93..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 @@ -6,25 +7,35 @@ from pathlib import Path from typing import TYPE_CHECKING -from toad.navigation_preparation import OpenThread, ThreadNavigationRequest, ThreadOpening +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.screens.session_view import SessionOrigin + from toad.session_scope import ProjectSessionOrigin from toad.navigation_preparation import ThreadNavigation @dataclass(frozen=True) class ThreadOrigin: - source: SessionOrigin + source: ProjectSessionOrigin selected_mode: str root: str | None identity: str @classmethod - def capture(cls, app: ToadApp, source: SessionOrigin) -> ThreadOrigin: - return cls(source, app.selected_mode, source.coordination_root, source.thread_identity.thread_name()) + 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: app = navigator.app @@ -41,12 +52,15 @@ 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 - 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 @@ -62,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) @@ -81,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_publication.py b/src/toad/transcript_publication.py index 6f0833c56..12a07a34d 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 @@ -9,50 +10,50 @@ from functools import partial from typing import TYPE_CHECKING, TypeGuard -from agent_comms.acp_extension import ( - InputStartedUpdate, - TranscriptDraftUpdate, - TranscriptSnapshotUpdate, -) -from agent_comms.child_process import join_retirement -from agent_comms.coordination_errors import ( - CoordinationReadUnavailable, - StaleRevision, - TranscriptChanged, -) -from agent_comms.mro_dispatch import MroDispatch, handles -from agent_comms.transcript_events import TranscriptCoverage from agent_comms.transcripts import TranscriptCursor, TranscriptPage +from agent_comms.transcript_events import TranscriptCoverage +from agent_comms.acp_extension import InputStartedUpdate, TranscriptDraftUpdate, TranscriptSnapshotUpdate +from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.ui_model.observation import ( NotPublished, Published, TranscriptPublicationState, ) -from textual.await_complete import AwaitComplete +from agent_comms.coordination_errors import ( + CoordinationReadUnavailable, + StaleRevision, + TranscriptChanged, +) from textual.worker import Worker, WorkerCancelled - -from toad.core.events import CoreEventStream -from toad.resource_release import AsyncResourceRelease, ResourceRelease -from toad.screens.session_view import SessionView -from toad.transcript_preparation import PreparedPageSource +from textual.await_complete import AwaitComplete +from toad.widgets.history_anchor import ( + HeldReaderDestination, + TailPosition, + HistoryWindow, +) from toad.widgets.committed_presentation import ( CommitBarrier, - CommitEvidence, CommitParticipant, + CommitEvidence, commit_plan, protected_blocks, retirement_candidates, ) -from toad.widgets.history_anchor import ( - HeldReaderDestination, - HistoryWindow, - TailPosition, -) +from agent_comms.child_process import join_retirement + +from toad.core.events import CoreEventStream +from toad.resource_release import AsyncResourceRelease, ResourceRelease +from toad.transcript_preparation import PreparedPageSource from toad.widgets.session_details import SessionDetails from toad.widgets.transcript_history import TranscriptHistory +from toad.session_scope import ( + NoTranscript, + SessionOrigin, + SessionScope, + TranscriptScope, +) if TYPE_CHECKING: - from toad.screens.session_view import SessionOrigin from toad.widgets.conversation import Conversation @@ -61,14 +62,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(): @@ -90,17 +94,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: @@ -108,14 +117,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. @@ -139,7 +153,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. @@ -152,7 +166,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: @@ -184,7 +200,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.""" @@ -199,7 +218,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) @@ -217,7 +236,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: @@ -230,7 +252,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 @@ -288,8 +312,11 @@ 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.""" @@ -325,7 +352,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) @@ -336,13 +365,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(): @@ -364,7 +404,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, ) @@ -403,7 +445,6 @@ def publication_current(): return True - class ViewBinding: """The native view's binding and its view-dependent operations.""" @@ -414,8 +455,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 @@ -473,30 +514,42 @@ 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) + 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) @@ -514,28 +567,33 @@ 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): 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 + ) @property def history(self) -> TranscriptHistory: return self._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): @@ -557,7 +615,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 + ) self._history = replacement removal = history.remove() # Both native membership changes happen before yielding: callbacks @@ -572,7 +632,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) @@ -584,7 +644,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): @@ -621,33 +683,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() @@ -694,14 +782,18 @@ 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: + 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 @@ -710,7 +802,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 @@ -738,16 +829,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 @@ -774,7 +861,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.""" @@ -789,7 +878,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() @@ -801,7 +892,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: @@ -845,7 +940,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. @@ -920,7 +1014,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/widgets/channel_activity.py b/src/toad/widgets/channel_activity.py index 5c8ced1a0..6712cb26e 100644 --- a/src/toad/widgets/channel_activity.py +++ b/src/toad/widgets/channel_activity.py @@ -5,7 +5,7 @@ from textual.containers import VerticalGroup from textual.content import Content -from toad.screens.session_view import SessionView +from toad.session_scope import SessionScope from toad.status_thread import StatusThread from toad.widgets.session_details import ConversationSessionDetails from toad.widgets.sidebar_tree import KeyedRows, SidebarChange, SidebarModelView @@ -48,7 +48,7 @@ def member_keys(self): def accepts_observation(self) -> bool: return self.view.query_ancestor( - SessionView + 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 f6a15d9f1..f77f32910 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -37,7 +37,7 @@ from toad.status_thread 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 NoTranscript, SessionScope from toad.command_catalog import CommandCatalog @@ -192,9 +192,9 @@ def prepare_prompt(self) -> None: prompt.sync_session() prompt.shell_mode = False prompt.update_prompt() - from toad.screens.session_view import SessionView + from toad.session_scope import SessionScope - if self.query_ancestor(SessionView).is_current: + if self.query_ancestor(SessionScope).is_current: prompt.focus() def watch_agent_binding(self, agent) -> None: @@ -213,9 +213,9 @@ def referenced_identity(self, references) -> object: self.refresh_thread_status() return self.DISCARD - 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.""" @@ -223,7 +223,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) @@ -285,7 +285,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) self.refresh_identity(source.loader.comms.root) diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 499646dd4..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,14 +17,17 @@ 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 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 @@ -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(SessionView).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,16 +450,22 @@ 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): 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") @@ -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 82ba27c63..75a31c9c9 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -8,17 +8,17 @@ from operator import attrgetter from pathlib import Path from time import monotonic, time_ns -from typing import TYPE_CHECKING, Callable, Literal +from typing import TYPE_CHECKING, Callable from acp import schema as acp_protocol from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution 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 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 @@ -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 @@ -67,6 +67,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 @@ -81,8 +82,9 @@ InputDeliveryObservation, SessionObservation, ) +from toad.session_scope import SessionScope, TranscriptScope from toad.shell import Shell -from toad.status_thread import NoStatusThread, StatusThread +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 @@ -116,7 +118,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()) @@ -131,7 +132,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"""\ @@ -153,7 +153,6 @@ def make_session_title(prompt: str) -> str: """ - class Loading(ConversationBlock, Static): """Tiny widget to show loading indicator.""" @@ -201,8 +200,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: @@ -213,14 +216,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( @@ -233,14 +246,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): @@ -258,7 +280,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 @@ -279,8 +303,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 @@ -316,7 +344,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: @@ -349,38 +379,36 @@ def prepare_viewport(self) -> None: self.query_ancestor(Conversation).transcript.retry() - -class ConversationSessionBinding(WorkspaceHosted, ThreadStatusOwner, containers.Vertical): +class ConversationSessionBinding( + MroDispatch, + WorkspaceHosted, + ThreadStatusOwner, + DeliveryFailureView, + 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 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: + def transcript_scope(self) -> TranscriptScope: """The native session identity whose canonical transcript this view shows.""" - from toad.screens.session_view import SessionView - - origin = self.query_ancestor(SessionView).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 - 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.""" @@ -388,45 +416,51 @@ 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) - def thread_presented(self, presentation) -> None: - """A settled presentation refreshes the transcript source of the shown view.""" - from toad.screens.session_view import SessionView - + 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(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) + + self._unpublished_presentation = NoThreadPresentation() + self.transcript.source_requests.request( + ObservedSourcePublication, presentation + ) else: self._unpublished_presentation = presentation @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() - 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: 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) @@ -439,19 +473,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) + 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: @@ -460,20 +496,20 @@ 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(Path) - working_directory: var[str] = var("") _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("") + @property def model_selection_available(self) -> bool: return self.agent_binding.configuration.model.available @@ -486,19 +522,12 @@ def mode_selection_available(self) -> bool: native_history_status: var[type[CursorStatus]] = var(lambda: HiddenCursorStatus) - goal_display: var[GoalDisplay] = var(NoGoal()) - goal_execution: var[GoalExecution] = var(IdleGoalExecution()) - - status: var[str | Content | RichText] = var("") - - title = var("") - def __init__( self, project_path: Path, @@ -510,20 +539,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: Path, agent: AgentDefinition | None=None, agent_session_id: str | None=None, - session_pk: int | None=None, session_title: str | None=None, initial_prompt: str | None=None, - transcript: TranscriptPresentation | None = None) -> None: + def _initialize_session( + self, + project_path: Path, + agent: AgentDefinition | None = None, + agent_session_id: str | None = None, + session_pk: int | None = None, + session_title: str | None = None, + initial_prompt: str | None = 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 @@ -536,7 +582,8 @@ def _initialize_session(self, project_path: Path, agent: AgentDefinition | 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 @@ -550,9 +597,9 @@ def _initialize_session(self, project_path: Path, agent: AgentDefinition | 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 @@ -568,14 +615,18 @@ def _initialize_session(self, project_path: Path, agent: AgentDefinition | 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) @@ -617,7 +668,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, NoAgentConnection() + ) await self._release_source_resources() def resume_retained_history(self) -> None: @@ -629,7 +682,12 @@ 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) @@ -650,13 +708,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: @@ -669,9 +733,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) @@ -701,7 +763,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. @@ -723,15 +785,17 @@ 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): +class Conversation( + 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" @@ -766,11 +830,9 @@ class Conversation(DeliveryFailureView, ConversationCommsHandlers, DeclaredWidge ), ] - 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 @@ -787,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. @@ -812,9 +876,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): @@ -898,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 ), @@ -918,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 @@ -954,7 +1019,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.""" @@ -978,7 +1043,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_( @@ -992,7 +1057,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: @@ -1001,23 +1066,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) - - 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) + self.feedback.flash(event.content, duration=event.duration, style=event.style) @property def terminal_action_state(self) -> bool | None: @@ -1035,21 +1084,19 @@ 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 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 @@ -1064,9 +1111,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 "")) @@ -1088,7 +1133,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 @@ -1119,12 +1166,15 @@ 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, - 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() @@ -1139,7 +1189,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 {}, @@ -1166,7 +1218,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: @@ -1194,23 +1246,29 @@ 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 - self.flash( + self.feedback.flash( Content.from_markup( "Model [b]$model[/] · thinking [b]$level", model=model.name, @@ -1242,8 +1300,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: @@ -1254,13 +1315,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( @@ -1271,20 +1336,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: - 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 - and cursor is not None and cursor.accepts(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) + 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()) @@ -1303,9 +1365,7 @@ async def on_update_status_line(self, message: CoreEventMessage) -> None: 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( @@ -1319,14 +1379,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) -> None: message.stop() @@ -1342,14 +1398,17 @@ async def present_started_input(self, started) -> None: self.output.boundary() if started.text: if not self.transcript.covers_input(started): - 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: @@ -1362,7 +1421,6 @@ def on_worker_state_changed(self, message) -> None: @handles(core_events.Thinking) async def on_acp_agent_thinking(self, message: CoreEventMessage) -> None: 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) @@ -1399,7 +1457,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() @@ -1417,7 +1475,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", ) @@ -1456,38 +1514,41 @@ 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) 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 + 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. 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() @@ -1495,15 +1556,19 @@ 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() - 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) @@ -1550,9 +1615,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") @@ -1580,7 +1643,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) -> None: event.stop() @@ -1590,9 +1652,9 @@ async def on_goal_control(self, event: CoreEventMessage) -> None: 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.""" @@ -1600,7 +1662,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", ) @@ -1626,10 +1688,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 @@ -1677,8 +1737,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) @@ -1805,8 +1868,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 @@ -1838,7 +1903,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: @@ -1877,7 +1942,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() @@ -1885,13 +1952,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: @@ -1939,22 +2006,23 @@ def refresh_block_cursor(self, show: Callable[[BlockCursor], None]) -> 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() self.refresh_bindings() - def _present_block_cursor(self, cursor, show: Callable[[BlockCursor], None]) -> None: - from toad.screens.session_view import SessionView - + def _present_block_cursor( + self, cursor: BlockCursor, show: Callable[[BlockCursor], None] + ) -> 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(SessionView).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/prompt.py b/src/toad/widgets/prompt.py index 654b9347f..65c937617 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 NoAgentConnection +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( @@ -310,16 +334,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: @@ -361,24 +390,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 @@ -390,7 +429,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", ) ) @@ -409,8 +450,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 @@ -467,10 +509,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" @@ -508,7 +547,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) @@ -553,7 +594,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("") @@ -592,6 +633,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.""" @@ -601,13 +649,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 restore_draft(self, text: str) -> None: """Recover local input as an edit of the retained document and undo history.""" current = self.text @@ -630,16 +671,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() @@ -658,7 +707,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): @@ -686,7 +737,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: @@ -695,6 +748,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: @@ -718,11 +772,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))) @@ -748,7 +807,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() @@ -761,7 +822,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( @@ -793,7 +856,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() @@ -809,8 +877,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() @@ -837,7 +903,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): @@ -932,7 +1002,9 @@ def project_directory_updated(self) -> None: pass def update_prompt(self) -> None: - self.prompt_text_area.placeholder = self.simple_placeholder or f"Message {self.agent_info.plain or 'thread'}" + self.prompt_text_area.placeholder = self.simple_placeholder or ( + f"Message {self.agent_info.plain or 'thread'}" + ) self.prompt_text_area.highlight_language = "markdown" self.prompt_label.refresh() diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 9cb155c16..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() @@ -894,12 +968,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/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 67f3e3f17..221d81727 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -29,7 +29,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 @@ -47,7 +51,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: @@ -113,8 +119,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 @@ -123,6 +131,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): @@ -174,8 +183,12 @@ def place_row(self, row, index: int) -> None: def reconcile_keys(self, keys, *, changed: frozenset[KeyT]): 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: @@ -198,16 +211,20 @@ def reconcile_keys(self, keys, *, changed: frozenset[KeyT]): 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 -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 @@ -227,8 +244,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] = {} @@ -261,7 +285,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() @@ -285,9 +311,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() @@ -360,8 +393,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: @@ -595,7 +632,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: @@ -656,11 +694,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.""" @@ -680,7 +722,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() @@ -723,7 +767,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 @@ -756,7 +803,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() @@ -783,12 +832,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 @@ -797,38 +853,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 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) + 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) @@ -840,7 +914,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 11eea4c92..621175f98 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 @@ -26,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: @@ -62,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 @@ -84,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) @@ -102,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) @@ -113,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]): @@ -131,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 @@ -146,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] @@ -166,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.""" @@ -182,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 @@ -209,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 @@ -224,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 @@ -257,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: @@ -279,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: @@ -296,10 +378,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 @@ -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 535cb78a4..79da3e028 100644 --- a/src/toad/widgets/thread_identity.py +++ b/src/toad/widgets/thread_identity.py @@ -1,18 +1,18 @@ """Panels read identity from their logical session, not the native workspace.""" + from agent_comms.mro_dispatch import handles from textual.widget import Widget from toad.core.events import ThreadIdentityChanged from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver +from toad.session_scope import SessionScope from toad.thread_identity import SessionIdentity 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 a56564fb9..d759934ce 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -43,7 +43,7 @@ from toad.line_blocks import ToggleDetails from toad.message_rows import MessageClock, MessageRow from toad.response_delivery import ResponseDelivery -from toad.screens.session_view import SessionView +from toad.session_scope import SessionScope from toad.transcript_filter import FilterState from toad.transcript_preparation import ( ClosedHistory, @@ -1181,7 +1181,7 @@ class TranscriptHistory[ViewT: "Conversation"](CommittedHistory, ConversationHis @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 16bd2a1da..b8b7b78a8 100644 --- a/src/toad/widgets/wire_history.py +++ b/src/toad/widgets/wire_history.py @@ -8,7 +8,7 @@ from agent_comms.historical_views import ConversationObservation from agent_comms.messages import Message -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 ( @@ -64,7 +64,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 8aa1d78f1..17032c88c 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -401,12 +401,12 @@ def status_thread(self, access): return self.loader.conversation.status_thread(self.loader.comms.root, access) def command_context(self, view): - from toad.screens.session_view import SessionView + from toad.session_scope import SessionScope from toad.target_commands import TargetContext references = view.app.coordination_access.observation.observed().sidebar.observation.require_snapshot().references return TargetContext(view.app, self.loader.comms, self.loader.conversation.destination(references).name, 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..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 @@ -12,10 +13,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): @@ -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,41 +51,61 @@ 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: "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..5bfdd47bd 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -1,19 +1,27 @@ """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 pathlib import Path 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, +) 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 +31,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 @@ -44,7 +52,10 @@ def commands(self) -> set: ... def coordination_root(self): ... @abstractmethod - def sidebar_focus_target(self): ... + def focus_sidebar_return(self) -> None: ... + + @abstractmethod + def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: ... @abstractmethod def sidebars(self): @@ -71,19 +82,22 @@ 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 visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: + return NoTarget() def sidebars(self): return () 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): @@ -108,8 +122,15 @@ 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 visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: + return ( + sessions.target_for_mode(self.identity, root) + if sessions.owns(self.view) + else NoTarget() + ) def sidebars(self): return self.view.sidebars() @@ -140,21 +161,24 @@ 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": - """What sessions and histories requested from ``identity`` start from; None once it closed.""" - if self.app.session_navigation.get(identity) is not None or identity in self.resident: + 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) -> "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 +189,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,18 +218,21 @@ 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): + def visible_target(self, root: Path | None) -> 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.source.visible_target(self, root) - def target_for_mode(self, identity, root): + 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 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. @@ -214,10 +241,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_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_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/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/model/test_session_name.py b/tests/model/test_session_name.py new file mode 100644 index 000000000..6cb81edf2 --- /dev/null +++ b/tests/model/test_session_name.py @@ -0,0 +1,63 @@ +"""Session labels own whether thread presentation and identity may replace them.""" + +import pytest + +from toad.core.events import SessionChangedEvent +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 + + +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 = 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") + 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 diff --git a/tests/model/test_session_updates_after_binding_change.py b/tests/model/test_session_updates_after_binding_change.py index 3a11f3da0..727ad8640 100644 --- a/tests/model/test_session_updates_after_binding_change.py +++ b/tests/model/test_session_updates_after_binding_change.py @@ -1,52 +1,13 @@ """A session update that was being validated when its session or process was replaced publishes nothing.""" -import asyncio from acp import schema from support.agents import RecordedEvents, deliver -from toad.acp.agent_controller import ThreadTaskRunner -from toad.acp.agent_session import ActiveSessionDisposition from toad.acp.sdk_boundary import ValidateSessionUpdateTask from toad.core.events import CoreEvent -class HeldValidation(ThreadTaskRunner): - def __init__(self): - self.entered, self.release = asyncio.Event(), asyncio.Event() - async def run(self, task): - result = await super().run(task) - self.entered.set() - await self.release.wait() - return result -async def test_a_pending_update_is_dropped_after_the_session_or_process_is_replaced(app_agents, tmp_path, monkeypatch): - agent = app_agents.detached_agent('A') - emitted = RecordedEvents() - subscription = agent.events.subscribe(CoreEvent, emitted.receive) - for replace in ('binding', 'process'): - validation = HeldValidation() - monkeypatch.setattr(agent.controller, 'task_runner', validation) - pending = asyncio.create_task(deliver(agent, {'sessionUpdate': 'available_commands_update', - 'availableCommands': [{'name': 'stale', 'description': 'old source'}]})) - await validation.entered.wait() - if replace == 'binding': - agent.session_id = 'B' - agent.session_id = 'A' - else: - agent.session.close() - agent.session.disposition = ActiveSessionDisposition(agent.session) - emitted.clear() - validation.release.set() - assert await pending is None - assert agent.controller.commands == [] and emitted == [] - # A valid notification through the same exposed method publishes normally. - monkeypatch.undo() - await deliver(agent, {'sessionUpdate': 'available_commands_update', - 'availableCommands': [{'name': 'current', 'description': 'current source'}]}) - assert agent.controller.commands[0].name == 'current' - await agent.stop() - subscription.close() - async def test_comms_facts_survive_validation_workers_and_reach_the_message_route(app_agents, tmp_path): from agent_comms.acp_extension import TextRouteUpdate, encode_updates diff --git a/tests/native/test_native_catalog_launch.py b/tests/native/test_native_catalog_launch.py index dbc085ebb..91c89b7c2 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, application=with_catalog_agent) as native: ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider diff --git a/tests/native/test_native_client_requests.py b/tests/native/test_native_client_requests.py index 27b92b989..0868e6d5a 100644 --- a/tests/native/test_native_client_requests.py +++ b/tests/native/test_native_client_requests.py @@ -7,7 +7,8 @@ from support.agents import deliver from support.native import native_session from toad.db import DB -from toad.navigation_target import NavigationContext, channel_target +from toad.navigation_target import channel_target +from toad.navigation_context import NavigationContext 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 7bfb7f768..32d2fc92d 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 @@ -19,27 +20,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 @@ -48,31 +75,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/native/test_thread_open_during_slow_load.py b/tests/native/test_thread_open_during_slow_load.py index 01dccf025..26b6395f1 100644 --- a/tests/native/test_thread_open_during_slow_load.py +++ b/tests/native/test_thread_open_during_slow_load.py @@ -1,24 +1,36 @@ """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 -from threading import Event -from unittest.mock import patch from agent_comms.child_process import ProcessIdentity import os 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.navigation_preparation import ThreadNavigationRequest from toad.widgets.channels_sidebar import ChannelsSidebar from toad.widgets.comms_sidebar import ChannelGroup 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) @@ -27,21 +39,8 @@ async def test_a_slow_thread_open_uses_one_tab_and_ends_with_saved_history(works process_identity=ProcessIdentity.capture(os.getpid()))) comms.channels.create_tag("journey") comms.messaging.send("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 - - async with workspace.app(size=(139, 25), agent=peer("delayed_load"), application=started) as ui: + 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() source = app.selected_session @@ -73,50 +72,36 @@ def journey_group(roster): await ui.until(lambda: "fresh-peer" in group.members, why="the channel's members") tabs_before = tuple(app.tab_order.names) - entered, release = Event(), Event() - read = ThreadNavigationRequest.read - - def held_read(request): - entered.set() - assert release.wait(10) - return read(request) - open_peer = partial(app.thread_navigation.open, owner_mode=owner, project_path=project, target="fresh-peer") - with patch.object(ThreadNavigationRequest, "read", held_read): - assert await pilot.click(group.members["fresh-peer"]) - opening = asyncio.create_task(open_peer()) - try: - assert await asyncio.to_thread(entered.wait, 3) - duplicate = asyncio.create_task(open_peer()) - await ui.step() - assert tuple(app.tab_order.names) == tabs_before - assert len(app.thread_navigation.pending) == 1 and not duplicate.done() - release.set() - await ui.until(lambda: (project / "load-entered-fresh-peer").exists(), seconds=12, - why="the agent to begin loading the thread") - tab = ui.selected_tab - assert tab != owner - view = app.selected_session - 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) - details = app.session_tracker.get_session(tab) + assert await pilot.click(group.members["fresh-peer"]) + opening = asyncio.create_task(open_peer()) + duplicate = asyncio.create_task(open_peer()) + try: + await ui.until(lambda: (project / "load-entered-fresh-peer").exists(), seconds=12, + why="the agent to begin loading the thread") + tab = ui.selected_tab + assert tab != owner + view = app.selected_session + 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) + details = app.session_tracker.get_session(tab) + assert details.name.display == "fresh-peer" - assert details.name.display == "fresh-peer" - workspace.release("load-fresh-peer") - assert await opening == tab and await duplicate == tab - await ui.until_agent_ready(seconds=12) - await ui.until_shows("SAVED_FIRST_OPEN_fresh-peer") - 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 - # queue, and Toad accepts input for a Core thread only once its owner's queue is known. - # The first message after opening a thread is sent to a real owner in - # 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) - finally: - release.set() + workspace.release("load-fresh-peer") + 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_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") + # This peer has no Core input queue. The real-owner first-message case is + # 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 app.tab_sets_drawn) + finally: + opening.cancel() + duplicate.cancel() + await asyncio.gather(opening, duplicate, return_exceptions=True) diff --git a/tests/support/agents.py b/tests/support/agents.py index 7d40ea2aa..60c27e30a 100644 --- a/tests/support/agents.py +++ b/tests/support/agents.py @@ -7,6 +7,7 @@ import sys from typing import TYPE_CHECKING +from toad.session_scope import NoAgent from toad.core.events import CoreEvent if TYPE_CHECKING: @@ -55,7 +56,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/human_input_observer.py b/tests/support/core_adapters/human_input_observer.py index b319aa189..f98a59d3d 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -24,6 +24,7 @@ from toad.agent import AgentBase from toad.agent_schema import AgentDefinition from toad.app import ToadApp +from toad.session_scope import AgentAttached async def until(pilot, predicate, seconds=20): @@ -177,7 +178,7 @@ def phase(name): 'run_command': {'*': shlex.join([sys.executable, '-m', 'agent_comms.acp'])}, 'accepts_comms_root': True, }) - 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/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index 69fc606fd..ec8f15c7b 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/support/failed_close_app.py b/tests/support/failed_close_app.py deleted file mode 100644 index 6e6a6825e..000000000 --- a/tests/support/failed_close_app.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Toad as a user runs it in a terminal, whose conversation raises when it is closed. - -Run as a script by ``terminal/test_failed_close.py``: ``failed_close_app.py PROJECT WIRE_ROOT``. -""" - -from pathlib import Path -import sys -from unittest.mock import patch - -from toad.app import ToadApp -from toad.cli import run_terminal -from toad.widgets.conversation import Conversation -from toad.goal_interaction import GoalSession - -from agents import peer -from stylesheets import INSTALLED - - -class CloseStepFailed(Exception): - """The controlled failure of the first step of releasing a conversation.""" - - -class FailedCloseApp(ToadApp): - CSS_PATH = INSTALLED - - async def on_mount(self, event): - event.prevent_default() - await super().on_mount() - self.noting = self.set_interval(0.05, self.break_release) - - def break_release(self): - """Once a conversation is open, break its release and tell the test.""" - for conversation in self.screen.query(Conversation): - self.noting.stop() - (Path(self.project_dir) / "conversation-open").touch() - - -if __name__ == "__main__": - from agent_comms.launch_root import LaunchRoot - - LaunchRoot.from_path(sys.argv[2]).install() - with patch.object(GoalSession, "close", side_effect=CloseStepFailed("goal controls")): - run_terminal(FailedCloseApp(project_dir=sys.argv[1], agent_data=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 15e58a5a5..5b3d77792 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 @@ -228,7 +229,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: isinstance(ui.conversation.agent_binding, AgentBase), seconds=20, why="the bound agent") agent = ui.conversation.agent_binding diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index 4d39148fd..2666af304 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -9,7 +9,8 @@ from agent_comms.notification_progress import AnsweredProgress, IgnoredProgress 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_target import DirectTarget, channel_target +from toad.navigation_context import NavigationContext from toad.widgets.comms_chat import CommsChatView from toad.notification_status import CheckedNotifications, NotificationStatus diff --git a/tests/support/observed_app.py b/tests/support/observed_app.py index 3fc71cb5b..3511626db 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 @@ -36,4 +37,4 @@ def note_observer(self): from agent_comms.launch_root import LaunchRoot LaunchRoot.from_path(sys.argv[2]).install() - 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 44c9f5fc3..2b8e0c013 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -7,17 +7,19 @@ 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 if TYPE_CHECKING: from textual.pilot import Pilot + from toad.app import ToadApp @@ -102,11 +104,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 @@ -163,7 +169,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): @@ -175,13 +183,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): @@ -202,8 +216,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: @@ -223,8 +239,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): @@ -257,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 @@ -289,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_binding) + ) await self.step() 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 + 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: - from toad.navigation_target import DirectTarget, NavigationContext + 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/support/workspace.py b/tests/support/workspace.py index 3f2d71786..5038fd4e1 100644 --- a/tests/support/workspace.py +++ b/tests/support/workspace.py @@ -10,9 +10,10 @@ from support.agents import AppAgents from support.roots import Roots, installed_wire from support.ui import TextualUi +from toad.session_scope import AgentBinding, NoAgent if TYPE_CHECKING: - from toad.agent_schema import AgentDefinition + from agent_comms.comms import Comms class Workspace: @@ -78,7 +79,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. @@ -88,7 +89,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/terminal/test_failed_close.py b/tests/terminal/test_failed_close.py deleted file mode 100644 index 1d9c27ee4..000000000 --- a/tests/terminal/test_failed_close.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Quitting Toad when a step of closing raises still ends the process: the failure is saved and announced, the exit status is 1 and no process is left behind.""" - -from pathlib import Path -import sys - -from support.terminal import terminal_running - -APP = Path(__file__).parents[1] / "support" / "failed_close_app.py" - - -async def test_a_close_step_that_raises_still_ends_the_process_and_saves_the_failure(workspace, terminal_emulator): - import psutil - - logs = workspace.roots.attempt / "state" / "toad" / "logs" - async with terminal_running(workspace, terminal_emulator, (sys.executable, str(APP), str(workspace.project), str(workspace.roots.wire)), - size=(43, 140)) as terminal: - await terminal.until_shows("#comms") - await terminal.click_text("#comms") - await terminal.until((workspace.project / "conversation-open").exists, seconds=60, - why="a conversation to open") - started = psutil.Process(terminal.process.pid).children(recursive=True) - assert any("spawn_main" in " ".join(process.cmdline()) for process in started), started - await terminal.send("\x11") # ctrl+q - status = await terminal.until_exit(seconds=20) - tape = terminal.output.decode(errors="replace") - _, alive = psutil.wait_procs(started, timeout=5) - saved = [path.read_text() for path in logs.glob("Terminal_crash*.txt")] - assert status == 1, (status, tape[-2000:]) - assert any("CloseStepFailed: goal controls" in text for text in saved), saved - assert "Terminal traceback saved:" in tape, tape[-2000:] - assert not alive, [(process.pid, process.cmdline()) for process in alive] diff --git a/tests/textual/test_acp_client_requests.py b/tests/textual/test_acp_client_requests.py index 7889e4e72..9cb0871f5 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_agent_binding.py b/tests/textual/test_agent_binding.py new file mode 100644 index 000000000..db2612d1a --- /dev/null +++ b/tests/textual/test_agent_binding.py @@ -0,0 +1,65 @@ +"""Each launch binding keeps matching with its existing native view.""" + +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 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") + assert view.thread_identity == NoThreadIdentity() + + +async def test_attached_agent_matches_its_live_session_and_relaunch_reuses_the_view( + workspace, +): + definition = peer("echo") + async with workspace.app(binding=AgentAttached(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") + + +async def test_agent_session_matches_its_saved_session_without_crossing_agent_identity( + workspace, +): + definition = peer("echo") + 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.thread_identity.thread_name() == "retained-session" 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_block_menu_and_copy.py b/tests/textual/test_block_menu_and_copy.py index 244ead917..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" @@ -53,19 +65,42 @@ 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({ - "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() + 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") @@ -76,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") @@ -94,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_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index 1377908e3..be4771fdc 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -1,48 +1,34 @@ """A real shared observer updates a shown channel and leaves its parked view alone.""" -import os -from unittest.mock import patch - - -async def test_live_update_paints_parked_channel_unsubscribes_and_return_shows_the_update(workspace): +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 comms = workspace.comms for name in ("sender", "reader", session_thread_name(workspace.project)): workspace.thread(name, "alpha") - comms.messaging.send("sender", "#alpha", "INITIAL ROW") + initial = comms.messaging.send("sender", "#alpha", "INITIAL ROW").message async with workspace.app(size=(113, 34)) as ui: - app = ui.app + 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() - feedback = chat.message_history.notification_view - access = app.coordination_access - await ui.until(lambda: feedback in access.notification_views, - why="the channel's notification subscription") - assert access.observation.observed().process.process.pid != os.getpid() - arrivals = [] - publish = feedback.notifications_observed + 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", + ) - def received(results): - arrivals.append(results) - publish(results) + comms.messaging.send("sender", "#alpha", "LIVE UPDATE") + await ui.until_shows("LIVE UPDATE") - with patch.object(feedback, "notifications_observed", received): - comms.messaging.send("sender", "#alpha", "LIVE UPDATE") - await ui.until_shows("LIVE UPDATE") - await ui.until(lambda: bool(arrivals), why="the shared observer's notification reply") - await ui.select_tab(owner) - assert feedback not in access.notification_views - parked = len(arrivals) - comms.messaging.send("sender", "#alpha", "PARKED UPDATE") - await ui.step(1.2) - assert len(arrivals) == parked, "a parked channel received notification bodies" - assert feedback not in access.notification_views - await ui.select_tab(mode) - await ui.until_shows("PARKED UPDATE") - await ui.until(lambda: feedback in access.notification_views, - why="the returned channel's notification subscription") - assert app.selected_session.query_one(CommsChatView) is chat + 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 diff --git a/tests/textual/test_close_tab.py b/tests/textual/test_close_tab.py deleted file mode 100644 index b686d21a7..000000000 --- a/tests/textual/test_close_tab.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Textual-facing: closing a tab with a long mounted history drops it from the strip before its widgets are torn down, and the closing view is not drawn up again on the way out.""" - -from unittest.mock import patch - -from toad.agent_binding import NoAgentBound - -from toad.widgets.agent_response import AgentResponse -from toad.widgets.conversation import Conversation -from toad.widgets.prompt import Prompt - - -async def test_closed_tab_leaves_the_strip_before_its_view_is_removed(workspace): - async with workspace.app(size=(110, 35)) as ui: - app, _ = ui.textual - base = ui.selected_tab - for _ in range(2): - identity = await ui.new_session() - await ui.conversation.contents.mount(*[ - AgentResponse(f"Response {i}\n\n" + "long paragraphs.\n\n" * 10) for i in range(14)]) - await ui.step() - visible_at_teardown = [] - teardown = app.workspace_sessions.close - - async def watched_close(closing): - visible_at_teardown.append(identity in [tab.identity for tab in ui.tabs()]) - return await teardown(closing) - - with patch.object(app.workspace_sessions, "close", watched_close): - await ui.close_tab(identity) - assert visible_at_teardown == [False], "Closed tab remained visible during view teardown" - assert ui.selected_tab == base and identity not in app.tab_order.names - - -async def test_closing_a_tab_releases_its_agent_without_titling_or_configuring_the_closing_view(workspace): - async with workspace.app(size=(110, 35)) as ui: - app, _ = ui.textual - identity = await ui.new_session() - conversation = ui.conversation - # A bound agent, as an open native tab has: releasing it must not become another update of the view. - ui.attach_agent(ui.detached_agent()) - await ui.step() - prompt = conversation.prompt - updated = [] - sync_configuration, sync_project = Prompt.sync_configuration, Conversation.sync_project_path - - def configured(view, *args, **kwargs): - if view is prompt: - updated.append("prompt configuration") - return sync_configuration(view, *args, **kwargs) - - def synced(view, *args, **kwargs): - if view is conversation: - updated.append("project sync") - return sync_project(view, *args, **kwargs) - - with (patch.object(Conversation, "sync_project_path", synced), - patch.object(Prompt, "sync_configuration", configured)): - await ui.close_tab(identity) - assert not updated, ("The closing view was updated again", updated) - assert isinstance(conversation.agent_binding, NoAgentBound) and not conversation.is_attached diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 20f4ceec7..ba455bbf6 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -6,7 +6,7 @@ from agent_comms.channels import AllChannel from toad.constants import ALL_COMMS_TARGET from toad.conversation_kind import ChannelConversation, DmConversation, IrcConversation -from toad.navigation_target import NavigationContext +from toad.navigation_context import NavigationContext from toad.navigation_target import DirectTarget, FeedTarget, channel_target from toad.widgets.comms_chat import CommsChatView from toad.widgets.channel_activity import ChannelActivity diff --git a/tests/textual/test_default_route_flip.py b/tests/textual/test_default_route_flip.py deleted file mode 100644 index fda656d16..000000000 --- a/tests/textual/test_default_route_flip.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Published defaults select fresh launches; a running application's root stays explicit.""" - -import asyncio -import threading -from unittest.mock import patch - -import pytest -from agent_comms.active_route import ActiveRoute -from agent_comms.errors import HumanClaimBlockedError, HumanSendRefusedError, RelationViolationError -from agent_comms.launch_root import LaunchRoot -from agent_comms.route_selection import current_root - -from support.routes import private_root, publish_route, route_home -from toad.wire_history import WireSource -from toad.widgets.comms_chat import CommsChatView - - -async def test_a_published_default_does_not_rebind_a_running_app_or_its_acquired_read(workspace, monkeypatch): - home = route_home(monkeypatch, workspace.roots.attempt) - first = private_root(workspace.roots.attempt / "first", workspace.project, "FIRST_HISTORY") - second = private_root(workspace.roots.attempt / "second", workspace.project, "SECOND_HISTORY") - publish_route(home, first) - with ActiveRoute.published_launch().selected(): - async with workspace.app(size=(115, 38)) as ui: - await ui.open_channel("#team", actor="user") - await ui.until_shows("FIRST_HISTORY") - view = ui.app.selected_session.query_one(CommsChatView) - source = view.message_history.page_source.loader - entered, release = threading.Event(), threading.Event() - read = WireSource.read - - def held_read(source, request, through): - entered.set() - assert release.wait(8), "the acquired page was not released" - return read(source, request, through) - - with patch.object(WireSource, "read", held_read): - try: - source.comms.messaging.send("peer", "#team", "FIRST_LATE_PAGE") - await view._refresh() - assert await asyncio.to_thread(entered.wait, 8) - publish_route(home, second) - assert ActiveRoute.published_launch().path == second - assert current_root() == first - release.set() - await ui.until_shows("FIRST_LATE_PAGE") - assert ui.app.coordination_access.observation.observed().service.root == first - assert not ui.shows("SECOND_HISTORY") - finally: - release.set() - - -async def test_a_fresh_launch_uses_the_published_root_and_preserves_refused_and_blocked_drafts(workspace, monkeypatch): - from toad.core import input_events - - home = route_home(monkeypatch, workspace.roots.attempt) - first = private_root(workspace.roots.attempt / "first", workspace.project, "FIRST_HISTORY") - second = private_root(workspace.roots.attempt / "second", workspace.project, "SECOND_HISTORY") - publish_route(home, first) - with ActiveRoute.published_launch().selected(): - async with workspace.app(size=(115, 38)) as ui: - await ui.open_channel("#team", actor="user") - await ui.until_shows("FIRST_HISTORY") - publish_route(home, second) - assert ui.app.coordination_access.launch_root.path == first - - with ActiveRoute.published_launch().selected(): - async with workspace.app(size=(115, 38)) as ui: - await ui.open_channel("#team", actor="user") - await ui.until_shows("SECOND_HISTORY") - view = ui.app.selected_session.query_one(CommsChatView) - source = view.message_history.page_source.loader - assert source.comms.root == second - assert not ui.shows("FIRST_HISTORY") - with patch.object(source.comms.messaging, "send_user_message", - side_effect=HumanSendRefusedError(RelationViolationError("pre-append rejection"))) as rejected: - await view.submit_input(input_events.UserInputSubmitted("EDITABLE_REJECTION")) - await ui.until(lambda: not view.prompt.prompt_text_area.disabled, - why="the rejected draft editable on the fresh root") - 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 = "" - with patch.object(source.comms.messaging, "send_user_message", - side_effect=HumanClaimBlockedError("prior reservation cannot be retried")) as sender: - event = input_events.UserInputSubmitted("BLOCKED_NO_RETRY") - await view.submit_input(event) - await ui.until(lambda: view.prompt.prompt_text_area.disabled, - why="the blocked send visibly holds compose") - 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 - assert LaunchRoot.current().path == second diff --git a/tests/textual/test_delivery_draft.py b/tests/textual/test_delivery_draft.py new file mode 100644 index 000000000..ad884d2f7 --- /dev/null +++ b/tests/textual/test_delivery_draft.py @@ -0,0 +1,31 @@ +"""Actual mounted compose controls preserve the draft across delivery failures.""" + +import pytest +from agent_comms.acp_extension import ( + BackendDeliveryFailure, + ClaimBlockedFailure, +) + + +@pytest.mark.parametrize( + "failure", + [ + BackendDeliveryFailure("refused"), + ClaimBlockedFailure("blocked"), + ], +) +@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", actor=workspace.viewer) + 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_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 46b37da9e..b2cb7ded9 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -3,14 +3,12 @@ import asyncio from collections import Counter -from unittest.mock import patch -import pytest -from toad.agent_binding import NoAgentBound +from toad.agent_binding import NoAgentConnection from toad.core.input_events import UserInputSubmitted -from toad.screens.main import MainScreen, NavigatedOpeningContent +from toad.screens.main import MainScreen from toad.session_presentation import OperationalSessionSources @@ -37,24 +35,6 @@ def observed(self, event, subscription): self.publication.set() -async def test_a_failed_conversation_construction_keeps_the_initial_prompt_and_a_successful_one_transfers_it(workspace): - async with workspace.app(size=(110, 35)) as ui: - app, _pilot = ui.textual - owner = ObservedLaunchScreen(workspace.project, initial_prompt="CONSTRUCTOR_PENDING") - owner.content = NavigatedOpeningContent() - await app.session_navigation.new(lambda: owner) - with patch.object(owner.presentation.sources, "make_conversation", - side_effect=RuntimeError("Conversation construction failed")): - with pytest.raises(RuntimeError, match="construction failed"): - await owner._prepare_content() - assert owner.presentation.widget is None - assert owner._initial_prompt == "CONSTRUCTOR_PENDING" - await owner._prepare_content() - await owner.wait_content_ready() - await asyncio.wait_for(owner.publication.wait(), 12) - assert owner._initial_prompt is None - assert owner.conversation.take_initial_prompt() is None - assert owner.inputs == {"CONSTRUCTOR_PENDING": 1} async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_the_draft_and_undo_survive(workspace): @@ -66,7 +46,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() view = owner.conversation - assert isinstance(view.agent_binding, NoAgentBound) and not view.submissions.active + assert isinstance(view.agent_binding, NoAgentConnection) and not view.submissions.active assert owner._initial_prompt is None and view._initial_prompt is None assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1} @@ -88,7 +68,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() restored = owner.conversation - assert restored is not view and isinstance(restored.agent_binding, NoAgentBound) + assert restored is not view and isinstance(restored.agent_binding, NoAgentConnection) assert restored._initial_prompt is None and owner.presentation.state is None assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} editor = restored.prompt.prompt_text_area @@ -108,7 +88,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await ui.select_tab(owner.id) await ui.step() assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} - assert isinstance(owner.conversation.agent_binding, NoAgentBound) and not owner.conversation.submissions.active + assert isinstance(owner.conversation.agent_binding, NoAgentConnection) and not owner.conversation.submissions.active await app.session_navigation.close(owner.id) assert owner.presentation.widget is None and owner.presentation.state is None assert owner.id not in app.workspace_sessions.views diff --git a/tests/textual/test_interrupted_send.py b/tests/textual/test_interrupted_send.py deleted file mode 100644 index f51949e44..000000000 --- a/tests/textual/test_interrupted_send.py +++ /dev/null @@ -1,160 +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 -import pytest - -from agent_comms.comms import wire - - -@pytest.fixture -def conversation(workspace): - workspace.thread("peer", "team") - workspace.comms.messaging.send("peer", "#team", "INITIAL") - return workspace - - -def on_wire(root, body): - bus = wire(root).bus.bus - return [row for row in bus.since(0, bus.revision()) if row.body == body] - - -def times_on_wire(root, body): - return len(on_wire(root, body)) - - -async def test_a_direct_message_interrupted_while_its_worker_survives_is_not_replayed(conversation): - from toad.core import input_events - from toad.widgets.comms_chat import CommsChatView - - root = conversation.roots.wire - - async with conversation.app(size=(105, 32)) as ui: - await ui.open_direct("peer", actor=conversation.viewer) - view = ui.textual[0].screen.query_one(CommsChatView) - messaging = view.message_history.page_source.loader.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( - conversation): - from toad.core import input_events - from toad.widgets.comms_chat import CommsChatView - from toad.wire_history import WireSource - - root = conversation.roots.wire - - async with conversation.app(size=(105, 32)) as ui: - await ui.open_channel("#team", actor=conversation.viewer) - channel = ui.textual[0].screen.query_one(CommsChatView) - await ui.until_shows("INITIAL") - 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.page_source.loader.comms.messaging - with (patch.object(WireSource, "read", held_read), - patch.object(messaging, "send_user_message", wraps=messaging.send_user_message) as sender): - channel.prompt.text = "DIFFERENT_UNSENT_DRAFT" - sent = input_events.UserInputSubmitted("COMMITTED-NOT-DRAWN") - await channel.submit_input(sent) - rows = on_wire(root, sent.body) - assert len(rows) == 1 and sender.call_count == 1 - await ui.until(lambda: rows[0].message_id in channel.status, - why="the undrawn send's exact record in its visible status") - # The sent record owns the hold; a different draft remains untouched. - assert channel.send_block_reason - assert channel.prompt.text == "DIFFERENT_UNSENT_DRAFT" - 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 == "DIFFERENT_UNSENT_DRAFT" and not channel.prompt.prompt_text_area.disabled - - -async def test_a_send_whose_completion_fails_after_commit_shows_the_committed_record_and_is_not_resent( - conversation): - from agent_comms.errors import CommittedMessageError - from toad.core import input_events - from toad.widgets.comms_chat import CommsChatView - from toad.wire_history import WireSource - - root = conversation.roots.wire - - async with conversation.app(size=(105, 32)) as ui: - await ui.open_channel("#team", actor=conversation.viewer) - channel = ui.textual[0].screen.query_one(CommsChatView) - await ui.until_shows("INITIAL") - release = threading.Event() - real_read = WireSource.read - messaging = channel.message_history.page_source.loader.comms.messaging - original_send = messaging.send_user_message - committed = [] - - def held_read(source, request, through): - assert release.wait(8), "the held read was not released" - return real_read(source, request, through) - - def commit_then_fail(*args, **kwargs): - committed.append(original_send(*args, **kwargs)) - raise CommittedMessageError(committed[-1]) - - with (patch.object(WireSource, "read", held_read), - patch.object(messaging, "send_user_message", side_effect=commit_then_fail) as sender): - channel.prompt.text = "DIFFERENT_UNSENT_DRAFT" - sent = input_events.UserInputSubmitted("COMMITTED-THEN-FAILED") - await channel.submit_input(sent) - [record] = committed - await ui.until(lambda: record.message_id in channel.status, - why="the committed record in its visible status after completion failed") - assert times_on_wire(root, sent.body) == 1 and sender.call_count == 1 - # The exact committed record owns the status; nothing claims the message was not sent. - assert channel.send_block_reason == f"Sent {root}/{record.seq}/{record.message_id}; waiting to be drawn, do not retry" - assert channel.prompt.text == "DIFFERENT_UNSENT_DRAFT" - assert channel.prompt.prompt_text_area.disabled - await channel.submit_input(sent) - assert sender.call_count == 1 - release.set() - await ui.until(lambda: not channel.send_block_reason, why="the committed message drawn") - assert ui.shows(sent.body) - assert times_on_wire(root, sent.body) == 1 - assert channel.prompt.text == "DIFFERENT_UNSENT_DRAFT" diff --git a/tests/textual/test_key_bursts_move_focus_to_the_editor.py b/tests/textual/test_key_bursts_move_focus_to_the_editor.py index c03c9969f..915fd5086 100644 --- a/tests/textual/test_key_bursts_move_focus_to_the_editor.py +++ b/tests/textual/test_key_bursts_move_focus_to_the_editor.py @@ -4,7 +4,7 @@ must not be lost, reordered or applied to the wrong control. """ -from toad.agent_binding import NoAgentBound +from toad.agent_binding import NoAgentConnection from textual import events @@ -25,7 +25,7 @@ async def send_burst(ui, keys): async def test_a_character_and_an_immediate_backspace_in_one_burst_leave_nothing(workspace): async with workspace.app(size=(100, 35)) as ui: - assert isinstance(ui.conversation.agent_binding, NoAgentBound) + assert isinstance(ui.conversation.agent_binding, NoAgentConnection) editor = await send_burst(ui, (("x", "x"), ("backspace", None))) await ui.until(lambda: ui.shows("❯"), why="the editor drawn") await ui.step(.2) 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_navigation_route_reads.py b/tests/textual/test_navigation_route_reads.py index f438907a1..7093b35b8 100644 --- a/tests/textual/test_navigation_route_reads.py +++ b/tests/textual/test_navigation_route_reads.py @@ -1,75 +1,19 @@ """Textual-facing: while a route's metadata is read off the UI thread the prompt keeps taking keys, focus stays where the user moved it, and a superseded or orphaned opening creates no tab.""" -import asyncio -import threading -from unittest.mock import patch -from toad.navigation_preparation import CommsNavigationRequest, ThreadNavigationRequest -from toad.navigation_target import NavigationContext, ThreadTarget, channel_target -class Gate: - """Holds a request type's read on a worker thread until released.""" - def __init__(self, request_type): - self.request_type = request_type - self.entered, self.release = threading.Event(), threading.Event() - self.thread_ids = [] - self.original = request_type.read - def read(self, request): - self.thread_ids.append(threading.get_ident()) - self.entered.set() - if not self.release.wait(8): - raise TimeoutError("route read gate was not released") - return self.original(request) - def holding(self): - return patch.object(self.request_type, "read", lambda request: self.read(request)) -async def opening_while_the_user_types(workspace, request_type, target): - workspace.thread("metadata-peer", running=True) - async with workspace.app(size=(100, 32)) as ui: - app, _ = ui.textual - owner = ui.selected_tab - source = app.selected_session - other = await ui.new_session() - await ui.select_tab(owner) - gate = Gate(request_type) - context = NavigationContext(app, owner, workspace.project, source._comms_thread) - opening = None - try: - 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 ui.selected_tab == owner - await ui.write_draft("") - await ui.type("keep") - assert ui.draft().endswith("keep") - await asyncio.wait_for(ui.select_tab(other), 2) - assert not gate.release.is_set() and not opening.done() - gate.release.set() - assert await asyncio.wait_for(opening, 2) == other - assert ui.selected_tab == other, "Old route metadata stole focus" - assert len(ui.tabs()) == 2, "Superseded route created an unused tab" - assert not app.thread_navigation.pending - finally: - gate.release.set() - if opening is not None: - 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_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 @@ -80,30 +24,6 @@ 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): - workspace.thread("metadata-peer", running=True) - async with workspace.app(size=(100, 32)) as ui: - app, _ = ui.textual - owner = ui.selected_tab - source = app.selected_session - other = await ui.new_session() - await ui.select_tab(owner) - gate = Gate(CommsNavigationRequest) - opening = None - try: - with gate.holding(): - 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 - finally: - gate.release.set() - if opening is not None: - await asyncio.gather(opening, return_exceptions=True) diff --git a/tests/textual/test_observation_shutdown.py b/tests/textual/test_observation_shutdown.py index ba4bd29b6..583c04c6c 100644 --- a/tests/textual/test_observation_shutdown.py +++ b/tests/textual/test_observation_shutdown.py @@ -32,41 +32,6 @@ async def test_an_observer_dying_during_shutdown_still_releases_its_resources(wo assert cleaned.is_set() -async def test_shutdown_drains_an_acquired_service_read_after_its_waiter_is_cancelled(workspace, monkeypatch): - entered, release = threading.Event(), threading.Event() - acquired = [] - async with workspace.app() as ui: - access = ui.app.coordination_access - observing = access.observation.observed() - require = access.require - - def held_require(selected): - entered.set() - release.wait() - observed = require(selected) - acquired.append(observed) - return observed - - monkeypatch.setattr(access, "require", held_require) - reading = asyncio.create_task(access.transcripts.service(str(workspace.roots.wire))) - await asyncio.to_thread(entered.wait) - reading.cancel() - await asyncio.gather(reading, return_exceptions=True) - close_preparation = ui.app.preparation.aclose - - async def finish_accepted_read(): - release.set() - await close_preparation() - - monkeypatch.setattr(ui.app.preparation, "aclose", finish_accepted_read) - try: - observed, = acquired - assert observed.process is observing.process, "Shutdown reopened the observer after closing its original owner" - assert observed.service is observing.service - assert not observing.process.process.is_alive() - finally: - release.set() - await access.close() async def test_stopping_observation_keeps_reads_but_sends_no_late_interest(workspace): diff --git a/tests/textual/test_plan_display.py b/tests/textual/test_plan_display.py index 0b09aac87..eaf5a4b81 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 from contextlib import asynccontextmanager @@ -35,7 +36,7 @@ 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), binding=AgentAttached(peer("plan", name="Local ACP plan acceptance", identity="plan-acceptance"))) as ui: view = ui.conversation await ui.until(lambda: isinstance(view.agent_binding, AgentBase), why="the agent") agent = view.agent_binding diff --git a/tests/textual/test_prompt_completion.py b/tests/textual/test_prompt_completion.py index 868d48c3b..2d6f12ad7 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 @@ -30,7 +31,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), @@ -53,7 +54,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 @@ -80,7 +81,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), @@ -135,7 +136,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 " @@ -185,7 +186,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 3ae444c0f..e72921f6d 100644 --- a/tests/textual/test_prompt_shell_mode.py +++ b/tests/textual/test_prompt_shell_mode.py @@ -1,11 +1,12 @@ """Textual-facing: the prompt widget. Typing ! in an empty prompt enters shell mode for one submission.""" from support.agents import peer +from toad.session_scope import AgentAttached 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_queue_request_results.py b/tests/textual/test_queue_request_results.py index dc92b423d..a3cc63de8 100644 --- a/tests/textual/test_queue_request_results.py +++ b/tests/textual/test_queue_request_results.py @@ -2,19 +2,16 @@ session request produced when an older request answers late, and a failed send never rewrites the draft.""" import asyncio -from unittest.mock import patch import pytest from acp.schema import NewSessionResponse from agent_comms.acp_extension import ( - AvailableQueueProjection, QueueChangedUpdate, QueueItem, LiveQueueScope, NoQueueScope, UnavailableQueueProjection, - encode_updates, + AvailableQueueProjection, QueueChangedUpdate, QueueItem, LiveQueueScope, encode_updates, ) from agent_comms.child_process import ProcessIdentity from agent_comms.thread_identity import ClaimedIdentity, RecordedThreadIncarnation -from support.agents import PendingAnswer, PendingConnection, deliver -from toad.conversation_submission import DeferredInputSubmission +from support.agents import PendingAnswer, PendingConnection from toad.widgets.prompt import QueueSummary from acp.exceptions import RequestError @@ -73,45 +70,3 @@ async def test_late_answer_to_a_replaced_session_request_changes_neither_session else: assert view.submissions.queue_projection.status != "available" await agent.stop() - - -@pytest.mark.parametrize("successor", ["agent", "owner", "unavailable"]) -async def test_a_failed_retired_send_does_not_roll_back_the_successors_draft_or_queue(workspace, successor): - async with workspace.app(size=(115, 38)) as ui: - agent = ui.detached_agent("beta") - ui.attach_agent(agent) - view = ui.conversation - loaded = PendingAnswer() - loaded.result.set_result(response("beta")) - agent.process.connection = PendingConnection(load=loaded) - await agent.session.load() - await ui.step() - entered, release = asyncio.Event(), asyncio.Event() - - async def failed_send(*args, **kwargs): - entered.set() - await release.wait() - raise ValueError("delayed old request failure") - - with patch.object(agent, "send_prompt", side_effect=failed_send): - worker = await DeferredInputSubmission("old request").execute(view.submissions) - await entered.wait() - if successor == "agent": - ui.attach_agent(ui.detached_agent("beta")) - elif successor == "owner": - loaded.result = asyncio.get_running_loop().create_future() - loaded.result.set_result(response("beta", next_owner=True)) - await agent.session.load() - else: - await deliver(agent, { - "sessionUpdate": "session_info_update", - "_meta": encode_updates(QueueChangedUpdate(NoQueueScope(), 10, UnavailableQueueProjection()))}, - session_id="beta") - await ui.step() - await ui.write_draft("successor draft") - projection = view.submissions.queue_projection - release.set() - await worker.wait() - await ui.step() - assert ui.draft() == "successor draft" - assert view.submissions.queue_projection == projection 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_scope.py b/tests/textual/test_session_scope.py new file mode 100644 index 000000000..134695a11 --- /dev/null +++ b/tests/textual/test_session_scope.py @@ -0,0 +1,50 @@ +"""Session consumers discover the existing mounted owner without a screen cycle.""" + +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 AgentAttached, NoSessionOrigin, NoTranscript, SessionScope + + +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"): + unavailable.resolve_thread() + LaunchRoot.from_path(workspace.roots.wire).install() + definition = peer("echo") + async with workspace.app(binding=AgentAttached(definition)) 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") + assert app.workspace_sessions.views[first] is view + assert view.matches_launch( + definition.identity, ui.conversation.agent_binding.session_id + ) + 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 + 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") + await ui.close_tab(second) + 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_session_tool_calls.py b/tests/textual/test_session_tool_calls.py index 99322ded1..442833f2d 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 @@ -23,7 +24,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: isinstance(view.agent_binding, AgentBase), seconds=20, why="the agent to be created") agent = view.agent_binding diff --git a/tests/textual/test_sidebar_destinations.py b/tests/textual/test_sidebar_destinations.py index 7bb4e36f8..159b07977 100644 --- a/tests/textual/test_sidebar_destinations.py +++ b/tests/textual/test_sidebar_destinations.py @@ -1,43 +1,40 @@ -"""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 toad.navigation_target import NavigationTarget +from toad.widgets.comms_sidebar import CommsRow, CommsSidebar -from textual.app import App, ComposeResult -from textual.screen import Screen -from toad.navigation_target import NavigationContext, NavigationOwner, NavigationTarget -from toad.textual_colours import ThemedApp -from toad.widgets.comms_sidebar import CommsRow - - -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"] + 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") + 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] diff --git a/tests/textual/test_sidebar_state_across_views.py b/tests/textual/test_sidebar_state_across_views.py index 90adf39a2..2d50802bc 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 + 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.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( - workspace.comms.configuration.answer("worker")) + workspace.comms.configuration.answer("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.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,25 @@ 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.scroll_container.scroll_y assert actual_scroll == expected_scroll, ( - actual_scroll, expected_scroll, app.sidebar_state, - (current.scroll_container.scroll_y, current.scroll_container.max_scroll_y)) - assert app.sidebar_state.selected == SidebarSelection("#channel-28", "#channel-28") + actual_scroll, + expected_scroll, + app.sidebar_state, + (current.scroll_container.scroll_y, current.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 +141,9 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac assert not group(sidebar, "#any").expanded actual_scroll = sidebar.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 +160,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 +185,12 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac assert not any(row.has_class("-selected") for row in current.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.scroll_container.region) + member.region, + member.pseudo_classes, + member.has_focus, + app.get_widget_at(member.region.x, member.region.y), + current.scroll_container.region, + ) await pilot.hover(ui.conversation.prompt) assert "hover" not in member.pseudo_classes and member.current assert current.scroll_container.scroll_y == expected_scroll diff --git a/tests/textual/test_stale_route_writes.py b/tests/textual/test_stale_route_writes.py index 99c1a74a8..94657b292 100644 --- a/tests/textual/test_stale_route_writes.py +++ b/tests/textual/test_stale_route_writes.py @@ -1,10 +1,6 @@ """A mounted sender refuses a stale destination before entering the write worker.""" -import asyncio -from unittest.mock import patch -from agent_comms import route_selection -from agent_comms.errors import RouteChangedError from agent_comms.launch_root import LaunchRoot from support.roots import installed_wire @@ -47,51 +43,3 @@ async def test_a_route_change_during_service_acquisition_restores_the_draft(work assert not view.send_block_reason assert (first.bus.bus.revision(), second.bus.bus.revision()) == before assert ui.failure() is None - - -async def test_a_route_change_at_the_actual_write_boundary_restores_the_draft(workspace): - workspace.thread("peer", "team") - second = installed_wire(workspace.roots.attempt / "second") - async with workspace.app() as ui: - await ui.open_channel("#team", actor=workspace.viewer) - view = ui.app.selected_session.query_one(CommsChatView) - first = view.message_history.page_source.loader.comms - before = first.bus.bus.revision(), second.bus.bus.revision() - entered, release = asyncio.Event(), asyncio.Event() - refused = [] - selected_write = route_selection.run_selected_write - dispatch = asyncio.to_thread - - def in_changed_route(operation, *args, **kwargs): - with LaunchRoot.from_path(second.root).selected(): - try: - return operation(*args, **kwargs) - except RouteChangedError as error: - refused.append(error) - raise - - async def dispatch_after_route_change(operation, *args, **kwargs): - if operation is selected_write and args[1] == first.messaging.send_user_message: - entered.set() - await release.wait() - return await dispatch(in_changed_route, operation, *args, **kwargs) - return await dispatch(operation, *args, **kwargs) - - with patch.object(asyncio, "to_thread", dispatch_after_route_change): - sending = asyncio.create_task(view.submit_input(UserInputSubmitted("WORKER_ROUTE_REFUSED"))) - try: - await asyncio.wait_for(entered.wait(), 8) - await ui.until(lambda: view.prompt.prompt_text_area.disabled, - why="the in-flight send to hold compose") - release.set() - await sending - await ui.until(lambda: view.prompt.text == "WORKER_ROUTE_REFUSED" - and not view.prompt.prompt_text_area.disabled, - why="the worker-refused draft to be editable") - assert len(refused) == 1 - finally: - release.set() - await asyncio.gather(sending, return_exceptions=True) - assert not view.send_block_reason - assert (first.bus.bus.revision(), second.bus.bus.revision()) == before - assert ui.failure() is None 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/tests/textual/test_tab_underline.py b/tests/textual/test_tab_underline.py index dcb9b4cdb..cf4bbcf04 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 @@ -13,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 @@ -21,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) @@ -30,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", + ) diff --git a/tests/textual/test_transcript_presentation_fencing.py b/tests/textual/test_transcript_presentation_fencing.py index b510be6b2..424135771 100644 --- a/tests/textual/test_transcript_presentation_fencing.py +++ b/tests/textual/test_transcript_presentation_fencing.py @@ -165,134 +165,6 @@ async def test_closing_the_transcript_mid_application_paints_nothing(workspace): assert not view.transcript.binding.matches(view) -@pytest.mark.parametrize("after_admission", (False, True)) -@pytest.mark.parametrize("wire", (False, True)) -async def test_cancelling_a_staged_replacement_keeps_old_rows_and_restores_the_read_bound(workspace, monkeypatch, after_admission, wire): - from dataclasses import replace - from types import MethodType - from textual.widget import Widget - from toad.widgets.transcript_lines import TranscriptLinesBatchRenderTask - from toad.widgets.comms_chat import CommsChatView - from support.textual_steps import refresh_chat - from toad.wire_history import ReplacedWireObservation - - if wire: - workspace.thread("sender", "scope") - workspace.thread("carol") - workspace.thread("dave") - workspace.comms.messaging.send("carol", "dave", "NEW_VISIBLE_SOURCE") - workspace.comms.messaging.send("sender", "#scope", "OLD_VISIBLE_SOURCE") - workspace.comms.channels.set_channel_any_mode("#scope", True) - else: - old = write_journal(workspace.project / "old.jsonl", assistant_entry("OLD_VISIBLE_SOURCE", id="old")) - thread = workspace.thread("source") - workspace.comms.registry.declare(replace(thread, session_file=SavedSessionFile(str(old)))) - async with workspace.app(resume=None if wire else "source") as ui: - if wire: - await ui.open_channel("#scope", actor=workspace.viewer) - await ui.until_shows("OLD_VISIBLE_SOURCE") - view = ui.app.selected_session.query_one(CommsChatView) if wire else ui.conversation - await ui.app.coordination_access.stop_observing() - if not wire: - await view.transcript.suspend() - history = view.message_history if wire else view.transcript.binding.history - await ui.until(lambda: history.source_ready, why="the original source to finish its work") - original_source, original_scope = history.page_source, history.page_source.scope - pages, through = tuple(history.pages), history.page_source.through - if wire: - workspace.comms.channels.update_tags("carol", add=frozenset({"scope"})) - else: - new = write_journal(workspace.project / "new.jsonl", assistant_entry("NEW_VISIBLE_SOURCE", id="new")) - workspace.comms.registry.declare(replace(thread, session_file=SavedSessionFile(str(new)))) - entered, release = asyncio.Event(), asyncio.Event() - render_for = TranscriptLinesBatchRenderTask.render_for - dispatch = asyncio.to_thread - remove_children = Widget.remove_children - render_owners = [] - - async def held_dispatch(function, *args, **kwargs): - if (not after_admission and isinstance(function, MethodType) - and isinstance(function.__self__, TranscriptLinesBatchRenderTask) - and not release.is_set()): - entered.set() - await release.wait() - return await dispatch(function, *args, **kwargs) - - async def held_removal(owner, *args, **kwargs): - removal = remove_children(owner, *args, **kwargs) - if after_admission and owner is history and not release.is_set(): - render_owners.append(asyncio.current_task()) - entered.set() - await release.wait() - return await removal - - monkeypatch.setattr(asyncio, "to_thread", held_dispatch) - monkeypatch.setattr(Widget, "remove_children", held_removal) - view.window.anchor() - view.prompt.focus() - async def publish(): - if wire: - await refresh_chat(view) - else: - view.transcript.dirty = view.transcript.commit_required = True - await view.transcript.publish(CommitPublication) - - pending = asyncio.create_task(publish()) - try: - await asyncio.wait_for(entered.wait(), 8) - await ui.step() - if not after_admission: - render_owners.extend(task for task in asyncio.all_tasks() - if any(entry.frame.f_code is render_for.__func__.__code__ - and entry.frame.f_locals["view"] is history - for entry in asyncio.capture_call_graph(task).call_stack)) - if after_admission: - assert history.page_source is not original_source - accepted_source = history.page_source - if wire: - assert not isinstance(await accepted_source.observe_changes(accepted_source.revision), ReplacedWireObservation) - else: - assert accepted_source.through.session_file == str(new) - assert tuple(history.pages) != pages - else: - assert "OLD_VISIBLE_SOURCE" in ui.painted(view.window) - assert "NEW_VISIBLE_SOURCE" not in ui.painted(view.window) - assert history.page_source is original_source - if wire: - assert isinstance(await original_source.observe_changes(original_source.revision), ReplacedWireObservation) - assert history.page_source.scope is original_scope and not original_scope.closed - assert tuple(history.pages) == pages - applying, = render_owners - assert applying.cancel() - await asyncio.gather(applying, return_exceptions=True) - if after_admission: - assert history.page_source is accepted_source and not accepted_source.closed - assert original_scope.closed - if wire: - assert not isinstance(await accepted_source.observe_changes(accepted_source.revision), ReplacedWireObservation) - else: - assert history.page_source.through.session_file == str(new) - else: - assert history.page_source is original_source - if wire: - assert isinstance(await original_source.observe_changes(original_source.revision), ReplacedWireObservation) - assert history.page_source.scope is original_scope and not original_scope.closed - assert history.page_source.through == through - assert tuple(history.pages) == pages and all(page.display for page in pages) - release.set() - await publish() - await ui.until_shows("NEW_VISIBLE_SOURCE") - if not wire: - assert "OLD_VISIBLE_SOURCE" not in ui.painted(view.window) - finally: - release.set() - for applying in render_owners: - if not applying.done(): - applying.cancel() - await asyncio.gather(*render_owners, return_exceptions=True) - if not pending.done(): - pending.cancel() - await asyncio.gather(pending, return_exceptions=True) @pytest.mark.parametrize("empty", (False, True)) diff --git a/tests/app/test_welcome_view.py b/tests/textual/test_welcome_view.py similarity index 76% rename from tests/app/test_welcome_view.py rename to tests/textual/test_welcome_view.py index 90092d6a4..2007051d7 100644 --- a/tests/app/test_welcome_view.py +++ b/tests/textual/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,11 +19,17 @@ 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 + 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"), "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 @@ -38,11 +45,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") @@ -53,7 +68,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") @@ -62,7 +77,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/textual/test_workspace_tabs_share_one_agent_connection.py b/tests/textual/test_workspace_tabs_share_one_agent_connection.py index 120c51d3f..040b86b81 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 AgentAttached, 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), binding=AgentAttached(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