diff --git a/src/toad/command_catalog.py b/src/toad/command_catalog.py index 01a61faf7..149039867 100644 --- a/src/toad/command_catalog.py +++ b/src/toad/command_catalog.py @@ -1,5 +1,6 @@ """One derived command view joins ACP advertisements and local declarations.""" +import asyncio from dataclasses import dataclass from collections.abc import Awaitable, Sequence from typing import TYPE_CHECKING @@ -11,6 +12,10 @@ from toad.widgets.conversation import Conversation +class CommandDiscoveryCancelled(asyncio.CancelledError): + """The view that requested commands changed before discovery completed.""" + + @dataclass(frozen=True) class CommandCatalog: entries: tuple[CommandPresentation, ...] @@ -60,10 +65,18 @@ def target_choices(self): @classmethod async def execute(cls, text: str, conversation: Conversation) -> bool: - catalog = await conversation.read_command_catalog() - name, _, arguments = text.partition(" ") - command = next((command for command in catalog.commands - if command.command == name), None) - if command is None: - return False - return await command.parse_arguments(arguments).apply(conversation) + try: + catalog = await conversation.read_command_catalog() + name, _, arguments = text.partition(" ") + command = next((command for command in catalog.commands + if command.command == name), None) + if command is None: + return False + return await command.parse_arguments(arguments).apply(conversation) + except CommandDiscoveryCancelled: + # A recognized UI command must never fall through to agent input + # because its original target disappeared during an awaited read. + conversation.app.notify( + "View changed; command was not run. Submit it again.", severity="warning" + ) + return True diff --git a/src/toad/target_commands.py b/src/toad/target_commands.py index 4814ff634..756d2b0a8 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -119,8 +119,10 @@ def parse_arguments(self, arguments: str) -> ContextualCommand: return self async def apply(self, conversation: Conversation, ctx: TargetContext) -> bool: + from toad.command_catalog import CommandDiscoveryCancelled + if await conversation.command_target_context() != ctx: - raise ValueError("Command target changed; refresh this view") + raise CommandDiscoveryCancelled("Command target changed; refresh this view") if not self.available(ctx): raise ValueError("Action is no longer available for this target") self.execute(ctx) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 6b69c51c6..6df12bde2 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -54,7 +54,7 @@ UpCursor, require_blocks, ) -from toad.command_catalog import CommandCatalog +from toad.command_catalog import CommandCatalog, CommandDiscoveryCancelled from toad.conversation_comms_handlers import ConversationCommsHandlers from toad.conversation_submission import ConversationSubmissions from toad.conversation_turn import ConversationTurn, TurnOwner @@ -1515,7 +1515,7 @@ async def command_target_context(self): 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") + raise CommandDiscoveryCancelled("Command view changed during discovery") return screen.thread_identity.command_target( lambda actor: TargetContext( self.app, comms, CommandTarget.named(actor), actor, nav.project_path, nav.owner_mode @@ -1551,7 +1551,7 @@ async def read_command_catalog(self) -> CommandCatalog: or source.navigation_context != navigation or agent is not self.agent_binding ): - raise ValueError("Command view changed during discovery") + raise CommandDiscoveryCancelled("Command view changed during discovery") return catalog @handles(core_events.LinkContextChanged)