From 974fe28532906bb0fa2a0fa94027fc592686be37 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:32:41 -0400 Subject: [PATCH 01/10] Declare conversation service parameters from Comms callers --- src/toad/conversation_kind.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index 3d1ff299f..a3a0ee081 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -98,15 +98,15 @@ def unread(self, observation): def send_target(self) -> NamedTarget: return self.retained_target() - def read_only(self, comms): + def read_only(self, comms: Comms) -> bool: return False @classmethod @abstractmethod - async def update_mentions(cls, view, comms) -> None: ... + async def update_mentions(cls, view, comms: Comms) -> None: ... @abstractmethod - async def status_text(self, comms, *, read_only: bool) -> str: ... + async def status_text(self, comms: Comms, *, read_only: bool) -> str: ... def activity_members(self, model): return (self.destination(model.observation.require_snapshot().references).name,) @@ -192,7 +192,7 @@ def resolve(cls, comms: Comms, me: str, name: str) -> ResolvedConversation: def compose_activity(cls, view) -> ComposeResult: yield ChannelActivity(view) - async def status_text(self, comms, *, read_only: bool) -> str: + async def status_text(self, comms: Comms, *, read_only: bool) -> str: return "Read-only saved view" if read_only else "" @property @@ -204,11 +204,11 @@ def prompt(self): return ChannelPrompt(placeholder=self.initial_placeholder) - def read_only(self, comms): + def read_only(self, comms: Comms) -> bool: return comms.bus.require_channel(self.target.name).read_only @classmethod - async def update_mentions(cls, view, comms): + async def update_mentions(cls, view, comms: Comms): from toad.widgets.channel_prompt import ChannelPrompt history = view.message_history @@ -284,7 +284,7 @@ def label(self, references: CurrentDisplayReferences) -> str: return f"@{self.destination(references).name}" @classmethod - async def update_mentions(cls, view, comms) -> None: + async def update_mentions(cls, view, comms: Comms) -> None: """Direct-message composers have no channel mention roster.""" @classmethod @@ -297,7 +297,7 @@ def prompt(self): return MessagePrompt() - async def status_text(self, comms, *, read_only: bool) -> str: + async def status_text(self, comms: Comms, *, read_only: bool) -> str: def read(): references = CurrentDisplayReferences( str(comms.root), registry=comms.registry.snapshot() From 8eba4adaee428eafa6408e92e5d80f0eda92bbed Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:35:19 -0400 Subject: [PATCH 02/10] Type bound and absent agent inputs and conversation family callers --- src/toad/agent_binding.py | 85 ++++++++++++++++------------ src/toad/conversation_kind.py | 102 ++++++++++++++++------------------ 2 files changed, 99 insertions(+), 88 deletions(-) diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 1706c14bb..805835989 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -1,8 +1,11 @@ """The agent itself is a bound connection; a saved view may be unbound.""" + from __future__ import annotations from abc import ABC, abstractmethod from functools import cached_property +from pathlib import Path +from typing import TYPE_CHECKING from agent_comms.thread_presentation import ThreadPresentation from textual.content import Content @@ -11,15 +14,26 @@ from toad.acp.agent_session import UnopenedSessionDisposition from toad.agent_presentation import LocalAgentPresentation +if TYPE_CHECKING: + from agent_comms.goal_actions import GoalAction + from agent_comms.session_load import ExistingSessionLoadPlan + + from toad.conversation_submission import AgentInputSubmission, ConversationSubmissions + from toad.conversation_turn import TurnOwner + from toad.core_event_carrier import CoreEventMessage + from toad.screens.main import MainScreen + from toad.session_observation import SessionObservation + from toad.session_presentation import OperationalSessionSources + from toad.widgets.conversation import Conversation + class AgentConnection(ABC): @abstractmethod - async def observe_thread_presentation( - self, presentation: ThreadPresentation | None - ) -> None: ... + async def observe_thread_presentation(self, presentation: ThreadPresentation | None) -> None: ... def thread_read(self): from toad.acp.comms_updates import NoPendingThreadRead + return NoPendingThreadRead() def model_info(self, session_name: str) -> Content: @@ -30,25 +44,25 @@ def model_info(self, session_name: str) -> Content: return Content(model.name + (f" · {level}" if level else "")) @abstractmethod - def attach_view(self, view) -> None: ... + def attach_view(self, view: Conversation) -> None: ... @abstractmethod - def refresh_projection(self, view) -> None: ... + def refresh_projection(self, view: Conversation) -> None: ... @abstractmethod - def start_session(self, view) -> None: ... + def start_session(self, view: Conversation) -> None: ... @abstractmethod - def start_agent(self, sources, view) -> None: ... + def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: ... @abstractmethod - def update_view(self, view) -> None: ... + def update_view(self, view: Conversation) -> None: ... @abstractmethod - def connected(self, view) -> None: ... + def connected(self, view: Conversation) -> None: ... @abstractmethod - def accepts_message(self, message) -> bool: ... + def accepts_message(self, message: CoreEventMessage) -> bool: ... @abstractmethod def matches_agent(self, agent) -> bool: ... @@ -56,6 +70,7 @@ def matches_agent(self, agent) -> bool: ... class NoAgentConnection(AgentConnection): """A real saved view before it acquires any native connection.""" + ready = False automatic_titles = True available_modes = () @@ -73,25 +88,25 @@ def configuration(self): def get_info(self) -> str: return "" - async def retire_surface(self, view) -> None: + async def retire_surface(self, view: Conversation) -> None: """There is no native surface to retire.""" - def attach_view(self, view) -> None: + def attach_view(self, view: Conversation) -> None: """There is no event stream to subscribe to.""" - def refresh_projection(self, view) -> None: + def refresh_projection(self, view: Conversation) -> None: """There is no native projection to read.""" - def start_session(self, view) -> None: + def start_session(self, view: Conversation) -> None: view.start_unbound_session() - def start_agent(self, sources, view) -> None: + def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: sources.create_agent(view) - def update_view(self, view) -> None: + def update_view(self, view: Conversation) -> None: view.agent_info = Content.styled("shell") - def connected(self, view) -> None: + def connected(self, view: Conversation) -> None: """There is no connected-agent notification.""" async def set_session_name(self, name: str) -> None: @@ -100,71 +115,71 @@ async def set_session_name(self, name: str) -> None: async def stop(self) -> None: """There is no agent process to stop.""" - def detach_surface(self, view) -> None: + def detach_surface(self, view: Conversation) -> None: """There is no agent subscription to detach.""" - def accepts_message(self, message) -> bool: + def accepts_message(self, message: CoreEventMessage) -> bool: return False def matches_agent(self, agent) -> bool: return False - async def project_changed(self, path) -> None: + async def project_changed(self, path: Path) -> None: """There is no native session project to persist.""" - def refresh_context(self, view) -> None: + def refresh_context(self, view: Conversation) -> None: """There is no native measurement to display.""" - async def set_model(self, model_id) -> str: + async def set_model(self, model_id: str) -> str: return "No agent is bound yet" - async def set_mode(self, mode_id) -> str: + async def set_mode(self, mode_id: str) -> str: return "No agent is bound yet" async def compact_context(self, instructions) -> None: """No native context exists to compact.""" - async def cancel_turn(self, view) -> None: + async def cancel_turn(self, view: Conversation) -> None: """No native turn exists to cancel.""" - def begin_submission(self, owner, submission): + def begin_submission(self, owner: ConversationSubmissions, submission: AgentInputSubmission): return owner.unbound(submission) - def accepts_failed_input(self, message) -> bool: + def accepts_failed_input(self, message: CoreEventMessage) -> bool: return False - def send_now(self, owner) -> None: + def send_now(self, owner: ConversationSubmissions) -> None: """No remote queue is bound.""" - async def get_goal_history(self, goal_id): + async def get_goal_history(self, goal_id: str): return () - async def update_goal(self, action, text: str): + async def update_goal(self, action: type[GoalAction], text: str): raise ValueError("Persistent goals require an agent-comms session") - def cancel_allowed(self, turn) -> bool: + def cancel_allowed(self, turn: TurnOwner) -> bool: return False async def cancel(self) -> bool: return False - def matches_session(self, session_id) -> bool: + def matches_session(self, session_id: str) -> bool: return False def borrowed_processes(self): return () - async def reconnect_session(self, source, plan) -> None: + async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None: source.presentation.sources.attach_started(source, plan) - async def change_project(self, view, path) -> None: + async def change_project(self, view: Conversation, path: Path) -> None: view.feedback.flash("Project changes require an agent-comms session", style="error") - def request_compaction(self, view, instructions) -> None: + def request_compaction(self, view: Conversation, instructions) -> None: view.feedback.flash("Context compaction requires an agent-comms session", style="error") async def observe_thread_presentation(self, presentation) -> None: """There is no connected agent to inform.""" - def invalidate_observation(self, observation) -> None: + def invalidate_observation(self, observation: SessionObservation) -> None: """No native source exists to schedule a read against.""" diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index a3a0ee081..dd7c35cbf 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -5,6 +5,8 @@ import asyncio from abc import abstractmethod from dataclasses import dataclass +from functools import partial +from pathlib import Path from typing import TYPE_CHECKING, ClassVar from agent_comms.channel_targets import ( @@ -32,6 +34,15 @@ from toad.widgets.sidebar_tree import UnpaintedChannel if TYPE_CHECKING: + from agent_comms.registry_document import RegistrySnapshot + from agent_comms.ui_model.sidebar import SidebarModel, SidebarObservation + + from toad.comms_root import CoordinationAccess + from toad.screens.comms import CommsScreen + from toad.screens.historical_sessions import HistoricalSessions + from toad.session_tracker import CommsViewKey + from toad.widgets.comms_chat import CommsChatView + from toad.widgets.wire_history import WireHistory from toad.wire_history import WireSource @@ -53,14 +64,14 @@ def destination(self, references: CurrentDisplayReferences) -> NamedTarget: ... @abstractmethod def retained_target(self) -> NamedTarget: ... - def threads_needed(self, snapshot, me): + def threads_needed(self, snapshot: RegistrySnapshot, me: str): return () - def channels_needed(self, root): + def channels_needed(self, root: str): return () @classmethod - def view_identity(cls, key): + def view_identity(cls, key: CommsViewKey): return key @abstractmethod @@ -73,27 +84,25 @@ def resolve(cls, comms: Comms, me: str, name: str) -> ResolvedConversation: ... def label(self, references: CurrentDisplayReferences) -> str: return self.destination(references).name - def status_thread(self, root, access) -> StatusThread: + def status_thread(self, root: Path, access: CoordinationAccess) -> StatusThread: return NoStatusThread() - def historical_sessions(self, create_view, references): + def historical_sessions(self, create_view: partial[HistoricalSessions], references: CurrentDisplayReferences): return create_view() @classmethod - async def toggle_irc(cls, screen) -> None: + async def toggle_irc(cls, screen: CommsScreen) -> None: from toad.navigation_target import FeedTarget await screen.open_sidebar_target(FeedTarget()) @classmethod - async def toggle_dm(cls, screen) -> None: + async def toggle_dm(cls, screen: CommsScreen) -> None: await screen.action_back_to_agent() screen.app.session_navigation.direct_messages() - def unread(self, observation): - return observation.channel_unread( - self.destination(observation.require_snapshot().references).name - ) + def unread(self, observation: SidebarObservation): + return observation.channel_unread(self.destination(observation.require_snapshot().references).name) def send_target(self) -> NamedTarget: return self.retained_target() @@ -103,30 +112,28 @@ def read_only(self, comms: Comms) -> bool: @classmethod @abstractmethod - async def update_mentions(cls, view, comms: Comms) -> None: ... + async def update_mentions(cls, view: CommsChatView, comms: Comms) -> None: ... @abstractmethod async def status_text(self, comms: Comms, *, read_only: bool) -> str: ... - def activity_members(self, model): + def activity_members(self, model: SidebarModel): return (self.destination(model.observation.require_snapshot().references).name,) - def active_members(self, model): + def active_members(self, model: SidebarModel): return tuple( - model.threads.rows[name] - for name in self.activity_members(model) - if model.threads.rows[name].active + model.threads.rows[name] for name in self.activity_members(model) if model.threads.rows[name].active ) @classmethod @abstractmethod - def compose_activity(cls, view) -> ComposeResult: ... + def compose_activity(cls, view: CommsChatView) -> ComposeResult: ... @abstractmethod def prompt(self): ... @classmethod - def seen_page(cls, history, seen): + def seen_page(cls, history: WireHistory, seen): """Acknowledge visible fragments using their original page's proof.""" if not seen: return None @@ -160,16 +167,14 @@ def destination(self, references: CurrentDisplayReferences) -> ChannelTarget: def retained_target(self) -> ChannelTarget: return self.target - def activity_members(self, model): - return model.channels.rows.get( - self.target.name, UnpaintedChannel(self.target.name) - ).members + def activity_members(self, model: SidebarModel): + return model.channels.rows.get(self.target.name, UnpaintedChannel(self.target.name)).members - def channels_needed(self, root): + def channels_needed(self, root: str): return ((root, self.target.name),) @classmethod - def view_identity(cls, key): + def view_identity(cls, key: CommsViewKey): from toad.session_tracker import ChannelViewAddress return ChannelViewAddress(key.root, key.conversation) @@ -177,19 +182,18 @@ def view_identity(cls, key): def page(self, source: WireSource, bounds: PageBounds) -> MessagePage: return source.comms.views.channel_display_page( self.target.name, - viewer=source.viewer, bounds=bounds, + viewer=source.viewer, + bounds=bounds, ) @classmethod def resolve(cls, comms: Comms, me: str, name: str) -> ResolvedConversation: snapshot = comms.registry.snapshot() resolved = ChannelTarget(name).resolve(snapshot, comms.bus.require_channel) - return ResolvedConversation( - snapshot.canonical_name(me), cls(ChannelTarget(resolved.channel.name)) - ) + return ResolvedConversation(snapshot.canonical_name(me), cls(ChannelTarget(resolved.channel.name))) @classmethod - def compose_activity(cls, view) -> ComposeResult: + def compose_activity(cls, view: CommsChatView) -> ComposeResult: yield ChannelActivity(view) async def status_text(self, comms: Comms, *, read_only: bool) -> str: @@ -208,7 +212,7 @@ def read_only(self, comms: Comms) -> bool: return comms.bus.require_channel(self.target.name).read_only @classmethod - async def update_mentions(cls, view, comms: Comms): + async def update_mentions(cls, view: CommsChatView, comms: Comms): from toad.widgets.channel_prompt import ChannelPrompt history = view.message_history @@ -224,10 +228,7 @@ async def update_mentions(cls, view, comms: Comms): snapshot.mention_candidates, conversation.destination(snapshot.references).name, ) - if ( - not await history.source_is_current(source) - or view.conversation != conversation - ): + if not await history.source_is_current(source) or view.conversation != conversation: return view.query_one(ChannelPrompt).set_mention_candidates(mentions) @@ -248,47 +249,44 @@ def destination(self, references: CurrentDisplayReferences) -> ThreadTarget: def retained_target(self) -> IncarnationThreadTarget: return IncarnationThreadTarget(self.peer) - def status_thread(self, root, access) -> StatusThread: + def status_thread(self, root: Path, access: CoordinationAccess) -> StatusThread: return StatusThread.for_conversation(root, self, access) - def historical_sessions(self, create_view, references): + def historical_sessions(self, create_view: partial[HistoricalSessions], references: CurrentDisplayReferences): return create_view(name=self.destination(references).name) @classmethod - async def toggle_dm(cls, screen) -> None: + async def toggle_dm(cls, screen: CommsScreen) -> None: await screen.action_back_to_agent() - def threads_needed(self, snapshot, me): + def threads_needed(self, snapshot: RegistrySnapshot, me: str): return snapshot.require(me).incarnation, self.peer - def unread(self, observation): - return observation.bus_unread( - self.destination(observation.require_snapshot().references).name - ) + def unread(self, observation: SidebarObservation): + return observation.bus_unread(self.destination(observation.require_snapshot().references).name) def page(self, source: WireSource, bounds: PageBounds) -> MessagePage: return source.comms.views.dm_display_page( self.retained_target(), - worktree=str(source.project), bounds=bounds, + worktree=str(source.project), + bounds=bounds, ) @classmethod def resolve(cls, comms: Comms, me: str, name: str) -> ResolvedConversation: snapshot = comms.registry.snapshot() resolved = ThreadTarget(name).resolve(snapshot, comms.bus.require_channel) - return ResolvedConversation( - snapshot.require(me).name, cls(resolved.thread.incarnation) - ) + return ResolvedConversation(snapshot.require(me).name, cls(resolved.thread.incarnation)) def label(self, references: CurrentDisplayReferences) -> str: return f"@{self.destination(references).name}" @classmethod - async def update_mentions(cls, view, comms: Comms) -> None: + async def update_mentions(cls, view: CommsChatView, comms: Comms) -> None: """Direct-message composers have no channel mention roster.""" @classmethod - def compose_activity(cls, view) -> ComposeResult: + def compose_activity(cls, view: CommsChatView) -> ComposeResult: view.thread_activity = ConversationSessionDetails(view.conversation.peer) yield view.thread_activity @@ -299,9 +297,7 @@ def prompt(self): async def status_text(self, comms: Comms, *, read_only: bool) -> str: def read(): - references = CurrentDisplayReferences( - str(comms.root), registry=comms.registry.snapshot() - ) + references = CurrentDisplayReferences(str(comms.root), registry=comms.registry.snapshot()) return comms.configuration.answer(self.destination(references).name) return (await asyncio.to_thread(read)).status_label @@ -309,7 +305,7 @@ def read(): class IrcConversation(ChannelConversation): @classmethod - async def toggle_irc(cls, screen) -> None: + async def toggle_irc(cls, screen: CommsScreen) -> None: await screen.action_back_to_agent() def label(self, references: CurrentDisplayReferences) -> str: From 1433f9a866480638ead150bc509bbc08693bd4f2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:35:37 -0400 Subject: [PATCH 03/10] Declare frame lifecycle and surface callback inputs from their owners --- src/toad/agent_surface.py | 65 +++++++++++++++++++++++----------- src/toad/frame_presentation.py | 49 +++++++++++++------------ 2 files changed, 71 insertions(+), 43 deletions(-) diff --git a/src/toad/agent_surface.py b/src/toad/agent_surface.py index ff4aeca62..0d01f9bb0 100644 --- a/src/toad/agent_surface.py +++ b/src/toad/agent_surface.py @@ -3,6 +3,7 @@ from weakref import ref from contextlib import AsyncExitStack from functools import partial +from typing import TYPE_CHECKING from agent_comms.mro_dispatch import MroDispatch, handles from toad.surface_binding import SurfaceBinding @@ -10,16 +11,34 @@ from toad.permission_presentation import DiffPermissionPresentation, InlinePermissionPresentation from toad.shell_output import ShellCommandOutput, ShellTerminalOutput +if TYPE_CHECKING: + from textual.message import Message + from textual.widget import Widget + + from toad.acp.agent_controller import AgentController + from toad.acp.permission_controller import PermissionRequest + from toad.acp.terminal_controller import TerminalController + from toad.acp.terminal_owner import OperationalTerminalOwner + from toad.ansi import TerminalState + from toad.answer import Answer + from toad.core.events import CoreEventStream + from toad.core_event_carrier import CoreEventMessage + from toad.shell_output import ShellOutput + from toad.terminal_execution import TerminalExecution + from toad.widgets.conversation import Conversation + from toad.widgets.terminal_tool import TerminalTool + + class AttachedSurfaceBinding(SurfaceBinding, MroDispatch): - def __init__(self, target, events): + def __init__(self, target: Conversation, events: CoreEventStream): self._target = ref(target) self.subscription = target.subscribe_core(events) - def prepare(self, controller) -> None: + def prepare(self, controller: AgentController) -> None: app = self.target.app controller.task_runner = ProcessTaskRunner(app.render_processes) - def owns_publication(self, message, target): + def owns_publication(self, message: CoreEventMessage, target: Conversation): if not self.subscription.accepts(message.subscription): return False if not self.owns(target) or not target.agent_binding.matches_agent(message.publisher): @@ -28,7 +47,9 @@ def owns_publication(self, message, target): return False return target.is_attached and message.publisher.tools.presentation(message.event) is not None - async def _post_native(self, message, target, widget, retirement): + async def _post_native( + self, message: CoreEventMessage, target: Conversation, widget: Widget, retirement: AsyncExitStack, + ): if not self.owns_publication(message, target): return False retirement.push_async_callback(target.transcript.retire_presentations, @@ -36,7 +57,7 @@ async def _post_native(self, message, target, widget, retirement): await target.post(widget, new_block=True) return self.owns_publication(message, target) and widget.is_attached - async def present_tool(self, message, target): + async def present_tool(self, message: CoreEventMessage, target: Conversation): from toad.tool_output import ToolOutput from toad.widgets.outgoing_message import OutgoingMessage from toad.widgets.tool_call import ToolCall @@ -79,7 +100,7 @@ def sync_queue(self) -> None: if (target := self.target) is not None: target.submissions.publish_pending() - def prepare_terminal(self, state) -> None: + def prepare_terminal(self, state: TerminalState) -> None: target = self.target if target is not None: state.update_size(*target.get_terminal_dimensions()) @@ -89,12 +110,12 @@ def prepare_shell(self, source) -> None: target.working_directory = source.working_directory source.update_size(*target.get_terminal_dimensions()) - async def present_shell(self, output) -> None: + async def present_shell(self, output: ShellOutput) -> None: if (target := self.target) is not None: await self.dispatch(output, target) @handles(ShellCommandOutput) - async def show_shell_command(self, output, target): + async def show_shell_command(self, output: ShellCommandOutput, target: Conversation): from toad.widgets.shell_result import ShellResult if not any(isinstance(child, ShellResult) and child.source is output @@ -102,7 +123,7 @@ async def show_shell_command(self, output, target): await target.post(ShellResult(output)) @handles(ShellTerminalOutput) - async def show_shell_terminal(self, output, target): + async def show_shell_terminal(self, output: ShellTerminalOutput, target: Conversation): if output.terminal is None: from toad.widgets.shell_terminal import ShellTerminal @@ -112,18 +133,20 @@ async def show_shell_terminal(self, output, target): terminal = await target.new_terminal() output.attach(terminal) - def shell_failed(self, error) -> None: + def shell_failed(self, error: Exception) -> None: if (target := self.target) is not None: target.app.show_error(error, "Unable to start shell; check your settings") - async def present_permission(self, request, view) -> None: + async def present_permission(self, request: PermissionRequest, view: Conversation) -> None: await self.dispatch(request.presentation, view, request) - def permission_changed(self, view) -> None: + def permission_changed(self, view: Conversation) -> None: view.refresh_bindings() @handles(DiffPermissionPresentation) - async def show_file_permission(self, presentation, view, request): + async def show_file_permission( + self, presentation: DiffPermissionPresentation, view: Conversation, request: PermissionRequest, + ): from toad.screens.permissions import PermissionReview screen = PermissionReview(request, view, presentation.diffs, self) @@ -140,14 +163,16 @@ async def show_file_permission(self, presentation, view, request): app.terminal_attention.release(screen) @handles(InlinePermissionPresentation) - async def show_inline_permission(self, presentation, view, request): + async def show_inline_permission( + self, presentation: InlinePermissionPresentation, view: Conversation, request: PermissionRequest, + ): from toad.widgets.acp_content import ACPToolCallContent from toad.tool_output import decode_content parts = tuple(preview for item in presentation.parts if (preview := decode_content(item).permission_preview()) is not None) - def answer(answer): + def answer(answer: Answer): request.answer(self, answer) ask = view.ask(request.options, presentation.title, @@ -162,7 +187,7 @@ def retire(): request.watch(self, retire) - def schedule_terminal_presentation(self, controller): + def schedule_terminal_presentation(self, controller: OperationalTerminalOwner): if (target := self.target) is not None: target.call_later(controller.start_terminal_presentation, target) @@ -176,22 +201,22 @@ def close(self) -> None: def target(self): return self._target() - def post(self, message): + def post(self, message: Message): target = self.target # MessagePump decides whether to accept messages while closing/closed. return target.post_message(message) if target is not None else False - def publish_terminal(self, controller, terminal_id, execution): + def publish_terminal(self, controller: TerminalController, terminal_id: str, execution: TerminalExecution): from toad.widgets.terminal_tool import TerminalTool return self.post(TerminalTool.Projection(self, controller, terminal_id, execution)) - def owns_terminal(self, projection, target): + def owns_terminal(self, projection: TerminalTool.Projection, target: Conversation): """Same original surface, controller lifetime and acquired address.""" if not self.owns(target): return False return projection.controller.owner.owns_terminal_projection(self, projection) - async def present_terminal(self, projection, target): + async def present_terminal(self, projection: TerminalTool.Projection, target: Conversation): if not self.owns_terminal(projection, target): return from toad.widgets.terminal_tool import TerminalTool diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 8465524e7..e578d1f20 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections import deque +from collections.abc import Callable, Iterable from dataclasses import replace from functools import partial from typing import TYPE_CHECKING, ClassVar @@ -19,6 +20,8 @@ from ui_theme.scheme import BackgroundColour, ThemeColour if TYPE_CHECKING: + from textual.css.styles import StylesBase + from toad.widgets.transcript_history import TranscriptPageView from toad.screens.workspace import WorkspaceScreen @@ -31,7 +34,7 @@ class ToneLayer(PaintLayer): __slots__ = ("styles", "paint_background", "paint_foreground", "auto_color", "layer_opacity", "text_style", "background_tint") - def __init__(self, styles, tone: Color | None, opacity: float, paint) -> None: + def __init__(self, styles: StylesBase, tone: Color | None, opacity: float, paint: TonePaint) -> None: self.styles = styles self.layer_opacity = styles.layer_opacity * opacity stated = paint.paint_background @@ -79,26 +82,26 @@ class FrameState(DeclaredFamily, affix="Frame"): def scene(self): return self - def displayed(self, frame): + def displayed(self, frame: FramePresentation): writing = WritingFrame(self.scene) frame.state = writing frame.screen.app._driver.call_after_flush(partial(frame.written, writing)) - def defer(self, frame, owner, callback): + def defer(self, frame: FramePresentation, owner: Widget, callback: Callable[[], None]): frame.callbacks[owner, callback] = None owner.call_after_refresh(frame.flush_owner, owner, callback) - def begin(self, frame): + def begin(self, frame: FramePresentation): frame.state = PendingFrame() frame.presented.clear() - def suspend(self, frame): + def suspend(self, frame: FramePresentation): frame.state = SuspendedFrame(self) - def resume(self, frame): + def resume(self, frame: FramePresentation): pass - def restore(self, frame): + def restore(self, frame: FramePresentation): self.begin(frame) frame.screen.refresh() @@ -124,13 +127,13 @@ def __init__(self, previous: FrameState): def scene(self): return self.previous.scene - def resume(self, frame): + def resume(self, frame: FramePresentation): self.previous.restore(frame) - def suspend(self, frame): + def suspend(self, frame: FramePresentation): pass - def displayed(self, frame): + def displayed(self, frame: FramePresentation): pass @@ -144,24 +147,24 @@ def __init__(self, scene: PendingFrame): def scene(self): return self._scene - def restore(self, frame): + def restore(self, frame: FramePresentation): frame.present() - def displayed(self, frame): + def displayed(self, frame: FramePresentation): pass class ClosedFrame(FrameState): - def displayed(self, frame): + def displayed(self, frame: FramePresentation): pass - def defer(self, frame, owner, callback): + def defer(self, frame: FramePresentation, owner: Widget, callback: Callable[[], None]): pass - def begin(self, frame): + def begin(self, frame: FramePresentation): pass - def suspend(self, frame): + def suspend(self, frame: FramePresentation): pass @@ -248,27 +251,27 @@ async def wait(self) -> bool: class EffectPhase: - def paint(self, owner, effect, strip): + def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip): return owner.paint_effects(strip, owner.effect_frame(effect)) - def keeps_arrivals(self, arrivals): + def keeps_arrivals[Item](self, arrivals: Iterable[Item]) -> Iterable[Item]: return arrivals - def on_new_arrival(self, owner, effect, pages): + def on_new_arrival(self, owner: AnimationEffects[Mix], effect: Effect[Mix], pages: deque[TranscriptPageView]): pass class EffectUnstarted(EffectPhase): - def paint(self, owner, effect, strip): + def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip): owner.run_effect(effect) return super().paint(owner, effect, strip) class EffectFinished(EffectPhase): - def keeps_arrivals(self, arrivals): + def keeps_arrivals[Item](self, arrivals: Iterable[Item]) -> Iterable[Item]: return () - def on_new_arrival(self, owner, effect, pages): + def on_new_arrival(self, owner: AnimationEffects[Mix], effect: Effect[Mix], pages: deque[TranscriptPageView]): for page in pages: page.arrivals.clear() owner.stop_effect(effect) From cf147a292d89f128bc06b480a6e7e7a0aa578938 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:36:38 -0400 Subject: [PATCH 04/10] Declare validated prompt blocks using the SDK content owner --- src/toad/acp/prompt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/acp/prompt.py b/src/toad/acp/prompt.py index 0e708de18..7d2c5240e 100644 --- a/src/toad/acp/prompt.py +++ b/src/toad/acp/prompt.py @@ -2,6 +2,7 @@ from pathlib import Path from acp import schema +from acp.helpers import ContentBlock from toad.file_kind import FileKind from toad.prompt.extract import extract_paths_from_prompt from toad.prompt.resource import load_resource, ResourceError @@ -9,7 +10,7 @@ from agent_comms.image_inputs import ImageInput, prompt_images -def build(project_path: Path, prompt: str) -> list[object]: +def build(project_path: Path, prompt: str) -> list[ContentBlock]: """Build the prompt structure and extract paths with the @ syntax. Args: From 34bd00602d808fb24d5e5ecdc0884a54fd07ec40 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:37:32 -0400 Subject: [PATCH 05/10] Type history, submission, context and ACP controller parameters from callers --- src/toad/acp/agent_controller.py | 70 +++++++++++++++++++++------- src/toad/conversation_submission.py | 62 +++++++++++++----------- src/toad/widgets/context_explorer.py | 67 ++++++++++++++------------ src/toad/wire_history.py | 50 +++++++++++--------- 4 files changed, 151 insertions(+), 98 deletions(-) diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index e1606858d..3ce351a53 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, replace from functools import partial +from typing import TYPE_CHECKING from acp.exceptions import RequestError from agent_comms.acp_extension import ( @@ -34,6 +35,19 @@ from .transcript_reader import CoordinationTranscriptReader +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Coroutine + from pathlib import Path + + from acp.schema import AvailableCommand + from agent_comms.acp_extension import QueueScope, TranscriptSnapshotUpdate + from pydantic import JsonValue + + from toad.acp.agent import Agent + from toad.core.events import AgentFail + from toad.render_backend import Renderer, RenderTask + + @dataclass(frozen=True) class SessionBinding: """Actual ACP binding identity, preserved only until session replacement.""" @@ -56,25 +70,25 @@ def accepts_snapshot(self, identity: TranscriptReadIdentity, class TaskRunner(ABC): @abstractmethod - async def run(self, task): ... + async def run[Result](self, task: RenderTask[Result]) -> Result: ... class ThreadTaskRunner(TaskRunner): - async def run(self, task): + async def run[Result](self, task: RenderTask[Result]) -> Result: return await asyncio.to_thread(task.execute) class ProcessTaskRunner(TaskRunner): - def __init__(self, processes) -> None: + def __init__(self, processes: Renderer) -> None: self.processes = processes - async def run(self, task): + async def run[Result](self, task: RenderTask[Result]) -> Result: return await self.processes.submit(task) class AgentController(OperationalTerminalOwner): """One operational source; the surface is an optional weak projection.""" - def __init__(self, agent, transcripts: CoordinationTranscriptReader) -> None: + def __init__(self, agent: Agent, transcripts: CoordinationTranscriptReader) -> None: super().__init__(agent) self.surface: SurfaceBinding = DetachedSurfaceBinding() self.task_runner: TaskRunner = ThreadTaskRunner() @@ -138,7 +152,9 @@ async def restore(self, binding: SurfaceBinding) -> None: - async def publish_transcript_snapshot(self, update, binding, session) -> None: + async def publish_transcript_snapshot( + self, update: TranscriptSnapshotUpdate, binding: SurfaceBinding, session: SessionBinding + ) -> None: if binding.target is None or not session.accepts_snapshot(update.identity, self.coordination): return # No view or matching request owns this publication. snapshot = await self.transcripts.publication(update) @@ -169,15 +185,15 @@ def publish_plan(self, entries: list[PlanItem]) -> None: self.plan_entries = entries self.agent.events.publish(Plan(entries)) - def publish_commands(self, commands) -> None: + def publish_commands(self, commands: list[AvailableCommand]) -> None: from toad.core.events import AvailableCommandsUpdate self.commands = commands self.agent.events.publish(AvailableCommandsUpdate()) - def start_operation(self, operation): + def start_operation[Result](self, operation: Coroutine[None, None, Result]) -> asyncio.Task[Result]: return self.agent.session.start_operation(operation) - async def operate(self, operation): + async def operate[Result](self, operation: Coroutine[None, None, Result]) -> Result: return await asyncio.shield(self.start_operation(operation)) async def submit(self, prompt: str, *, request: PromptRequest | None = None): @@ -188,7 +204,9 @@ async def submit(self, prompt: str, *, request: PromptRequest | None = None): prompt, command, authority, agent.queue_attachment.scope, agent.project_root_path), authority)) - async def _prompt_operation(self, operation, authority: ClientSessionRequest): + async def _prompt_operation[Result]( + self, operation: Callable[[], Awaitable[Result]], authority: ClientSessionRequest + ) -> Result: """Keep the request in flight until its operation ends.""" coordination = self.coordination self.prompt_in_flight += 1 @@ -199,17 +217,24 @@ async def _prompt_operation(self, operation, authority: ClientSessionRequest): if self.prompt_in_flight == 0 and authority.current and coordination is not None: self.start_operation(self.reconcile_turn(coordination, authority)) - async def reconcile_turn(self, coordination, authority) -> None: + async def reconcile_turn(self, coordination: CoordinationChangedUpdate, authority: ClientSessionRequest) -> None: """Release snapshot ordering through a fresh read of the same owner.""" self.require_owner(coordination, authority) await self.agent.thread_read().reconcile(self.transcripts) - def accepts_turn_snapshot(self, sequence): + def accepts_turn_snapshot(self, sequence: int): """A remote busy turn does not imply an ordered response on this client.""" return (self.prompt_in_flight == 0 and sequence == self.agent.presentation.turns.sequence) - async def _submit(self, prompt: str, command, authority, queue_scope, project): + async def _submit( + self, + prompt: str, + command: PromptRequest, + authority: ClientSessionRequest, + queue_scope: QueueScope, + project: Path, + ): submission = asyncio.current_task() if command.defer_display else None if submission is not None: self._deferred_submissions.add(submission) @@ -239,7 +264,7 @@ async def submit_blocks(self, content: list[dict[str, str]], command: ClearQueue return await self.operate(self._prompt_operation(partial(self._prompt, content, command, authority, agent.queue_attachment.scope), authority)) - async def _prompt(self, content, command, authority, queue_scope): + async def _prompt(self, content, command, authority: ClientSessionRequest, queue_scope: QueueScope): agent = self.agent def require(): @@ -270,7 +295,16 @@ def require(): published=request_failure.notification_published) return None - def _prompt_failed(self, command, authority, queue_scope, failure: ACPFailure, report, *, published: bool=False) -> None: + def _prompt_failed( + self, + command, + authority: ClientSessionRequest, + queue_scope: QueueScope, + failure: ACPFailure, + report: Callable[[AgentFail], None], + *, + published: bool = False, + ) -> None: agent = self.agent user_text = command.draft_text if command is not None else None if user_text: @@ -294,7 +328,7 @@ async def compact_context(self, instructions=None): return await self.operate(self._prompt_operation(partial(self._compact_context, instructions, authority), authority)) - async def _compact_context(self, instructions, authority): + async def _compact_context(self, instructions, authority: ClientSessionRequest): agent = self.agent try: response = await agent.process.prompt(agent, [{'type': 'text', 'text': ' '}], @@ -321,7 +355,7 @@ async def _cancel_prompt(self, authority: ClientSessionRequest): return False return authority.current - async def request_owner(self, method: str, **params): + async def request_owner(self, method: str, **params: JsonValue): if self.coordination is None: raise ValueError('This action requires an agent-comms thread.') from agent_comms.runtime import RuntimeProxy, socket_path @@ -347,7 +381,7 @@ def resolve(): finally: await proxy.close() - def require_owner(self, coordination, authority: ClientSessionRequest): + def require_owner(self, coordination: CoordinationChangedUpdate, authority: ClientSessionRequest): if authority.retired or self.coordination is None: raise ValueError('The connected owner session retired; refresh its state.') if (self.coordination.wire_root, self.coordination.thread) != (coordination.wire_root, coordination.thread): diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index affa770d5..9e439c6e9 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -5,17 +5,23 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from functools import partial -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar -from agent_comms.acp_extension import InputStartedUpdate, QueuePromptRequest, SteerPromptRequest +from agent_comms.acp_extension import InputStartedUpdate, QueuePromptRequest, QueueScope, SteerPromptRequest from agent_comms.declared_family import DeclaredFamily from toad import messages from acp.exceptions import RequestError from toad.acp.client_session import ClientSessionRequest +from toad.core.input_events import UserInputSubmitted +from toad.core_event_carrier import CoreEventMessage from agent_comms.acp_extension import PendingQueueProjection from toad.widgets.prompt import Prompt from toad.command_catalog import CommandCatalog +if TYPE_CHECKING: + from toad.acp.agent import Agent + from toad.widgets.conversation import Conversation + @dataclass(frozen=True) class InputSubmission(DeclaredFamily, affix="InputSubmission"): @@ -28,13 +34,13 @@ def text(self): @classmethod @abstractmethod - def matches(cls, event, view): ... + def matches(cls, event: UserInputSubmitted, view: Conversation): ... @abstractmethod - async def execute(self, owner): ... + async def execute(self, owner: ConversationSubmissions): ... @classmethod - def from_event(cls, event, view): + def from_event(cls, event: UserInputSubmitted, view: Conversation): kind = next(kind for kind in sorted(cls.members_with(InputSubmission), key=lambda kind: kind.priority, reverse=True) if kind.matches(event, view)) @@ -44,20 +50,20 @@ def from_event(cls, event, view): class NoInputSubmission(InputSubmission): priority = -1 @classmethod - def matches(cls, event, view): + def matches(cls, event: UserInputSubmitted, view: Conversation): return True - async def execute(self, owner): + async def execute(self, owner: ConversationSubmissions): pass class QueueNowInputSubmission(InputSubmission): priority = 100 @classmethod - def matches(cls, event, view): + def matches(cls, event: UserInputSubmitted, view: Conversation): if event.shell or event.body.strip(): return False return event.immediate and view.agent_binding.presentation.queue_supported - async def execute(self, owner): + async def execute(self, owner: ConversationSubmissions): projection = owner.queue_projection if projection.status == 'available' and projection.items: owner.send_now() @@ -66,9 +72,9 @@ async def execute(self, owner): class ShellInputSubmission(InputSubmission): priority = 90 @classmethod - def matches(cls, event, view): + def matches(cls, event: UserInputSubmitted, view: Conversation): return event.shell and bool(event.body.strip()) - async def execute(self, owner): + async def execute(self, owner: ConversationSubmissions): view = owner.view shell = view.shell if await shell.is_busy(): @@ -88,7 +94,7 @@ async def prepare(self): ... class UnboundSubmissionStart(SubmissionStart): - def __init__(self, owner, submission): + def __init__(self, owner: ConversationSubmissions, submission: AgentInputSubmission): self.owner, self.submission = owner, submission async def prepare(self): @@ -101,7 +107,7 @@ class AgentInputSubmission(InputSubmission): @abstractmethod def request(self): ... - async def feedback(self, execution): + async def feedback(self, execution: SubmissionExecution): view = execution.owner.view await view.turns.binding.present_input(view, self.text) if execution.retired: @@ -114,14 +120,14 @@ async def feedback(self, execution): view.turns.describe(waiting) await asyncio.sleep(0) - async def execute(self, owner): + async def execute(self, owner: ConversationSubmissions): view = owner.view return await owner.begin(self).prepare() class OrdinaryInputSubmission(AgentInputSubmission): @classmethod - def matches(cls, event, view): + def matches(cls, event: UserInputSubmitted, view: Conversation): if event.shell: return False return bool(event.body.strip()) @@ -133,7 +139,7 @@ def request(self): class DeferredInputSubmission(OrdinaryInputSubmission): priority = 70 @classmethod - def matches(cls, event, view): + def matches(cls, event: UserInputSubmitted, view: Conversation): if not super().matches(event, view): return False if event.immediate: @@ -142,7 +148,7 @@ def matches(cls, event, view): @property def request(self): return QueuePromptRequest(self.text, True) - async def feedback(self, execution): + async def feedback(self, execution: SubmissionExecution): # Native queue/start publications own confirmation and message display. pass @@ -150,7 +156,7 @@ async def feedback(self, execution): class ImmediateInputSubmission(OrdinaryInputSubmission): priority = 80 @classmethod - def matches(cls, event, view): + def matches(cls, event: UserInputSubmitted, view: Conversation): if not super().matches(event, view): return False return event.immediate and view.agent_binding.presentation.queue_supported @@ -160,7 +166,7 @@ def request(self): class SubmissionExecution(SubmissionStart): """Immutable source binding and the real outstanding local request.""" - def __init__(self, owner, submission, agent): + def __init__(self, owner: ConversationSubmissions, submission: AgentInputSubmission, agent: Agent): self.owner, self.submission, self.agent = owner, submission, agent self.authority = ClientSessionRequest(agent, agent.session_id) self.scope = agent.queue_attachment.scope @@ -224,17 +230,17 @@ async def complete(self, reason): class ConversationSubmissions: """Own pending local transactions; remote membership stays with QueueAttachment.""" - def __init__(self, view): + def __init__(self, view: Conversation): self.view = view self.active: list[SubmissionExecution] = [] - def begin(self, submission): + def begin(self, submission: AgentInputSubmission): return self.view.agent_binding.begin_submission(self, submission) - def unbound(self, submission): + def unbound(self, submission: AgentInputSubmission): return UnboundSubmissionStart(self, submission) - def start(self, submission, agent): + def start(self, submission: AgentInputSubmission, agent: Agent): execution = SubmissionExecution(self, submission, agent) self.active.append(execution) return execution @@ -247,13 +253,13 @@ def ready(self): def unavailable_reason(self): return self.view.operational_sources.unavailable_reason(self.view) - async def submit(self, event): + async def submit(self, event: UserInputSubmitted): text = event.body.strip() if not event.shell and text.startswith('/') and await CommandCatalog.execute(text, self.view): return return await self.view.operational_sources.submit(self, event) - def accepts_failure(self, message): + def accepts_failure(self, message: CoreEventMessage): return self.view.agent_binding.accepts_failed_input(message) @property @@ -284,7 +290,7 @@ def publish_pending(self): if (prompt := self.view.query_one_optional(Prompt)) is not None: prompt.sync_queue() - def finish(self, execution): + def finish(self, execution: SubmissionExecution): if execution in self.active: self.active.remove(execution) if execution.current: @@ -297,12 +303,12 @@ def reset(self): def send_now(self): return self.view.agent_binding.send_now(self) - def send_bound_now(self, agent): + def send_bound_now(self, agent: Agent): session, scope = agent.session_id, agent.queue_attachment.scope return self.view.run_worker(partial(self._send_now, agent, session, scope), group='send-queued-now', exclusive=True) - async def _send_now(self, agent, session, scope): + async def _send_now(self, agent: Agent, session, scope: QueueScope): try: if await agent.controller.send_now(): return diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 0e3936437..1f0498be8 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -5,7 +5,7 @@ import asyncio from dataclasses import dataclass, field from pathlib import Path -from typing import Callable +from typing import TYPE_CHECKING, Callable from acp.exceptions import RequestError from agent_comms.context_store import ContextManifestCursor @@ -33,6 +33,11 @@ from toad.widgets.thread_identity import ShowsThreadIdentity +if TYPE_CHECKING: + from agent_comms.working_memory_questions import SpanAnswer + from textual.message import Message + + @dataclass class ContextTreeIntent: """Detail selection and initial disclosures survive widget retirement. @@ -45,21 +50,21 @@ class ContextTreeIntent: query: str = "" cursor: ContextManifestCursor | None = None - def select_page(self, cursor) -> bool: + def select_page(self, cursor: ContextManifestCursor) -> bool: if cursor is None: return False self.cursor = cursor self.selected = None return True - def search_current(self, query, selected): + def search_current(self, query: str, selected): return self.query == query and self.selected is selected - def rebind(self, original, model) -> None: + def rebind(self, original: ContextNode, model: ContextNode) -> None: if self.selected is original: self.selected = model - def restore_detail(self, resolve, show_detail) -> bool: + def restore_detail(self, resolve, show_detail: Callable[[ContextNode], Worker[None]]) -> bool: if self.selected is None: return False node = resolve(self.selected.key) @@ -73,7 +78,7 @@ def restore_detail(self, resolve, show_detail) -> bool: class ContextTree(Tree[ContextNode]): """Native nodes, disclosure and cursor share the Tree's mounted lifetime.""" - def __init__(self, show_detail: Callable[..., Worker[None]], show_placeholder) -> None: + def __init__(self, show_detail: Callable[..., Worker[None]], show_placeholder: Callable[[str], None]) -> None: self.show_detail = show_detail self.show_placeholder = show_placeholder self.context_nodes = {} @@ -92,7 +97,7 @@ def clear(self): def on_unmount(self) -> None: self.context_nodes.clear() - def present(self, groups) -> None: + def present(self, groups: tuple[tuple[str, tuple[ContextNode, ...], bool], ...]) -> None: # Reconcile the original TreeNodes so Tree._build rebases its cursor. with self.prevent( Tree.NodeExpanded, @@ -115,7 +120,7 @@ def present(self, groups) -> None: self._reconcile(group, models) self.restore_detail("Select a context segment to inspect.") - def search_results(self, matches, description: str, query) -> None: + def search_results(self, matches: tuple[ContextNode, ...], description: str, query: str) -> None: with self.prevent(Tree.NodeExpanded, Tree.NodeCollapsed, Tree.NodeSelected): self.clear() self.root.set_label(f"{description} · {query}") @@ -126,7 +131,7 @@ def search_results(self, matches, description: str, query) -> None: "Select a matching original source to read its full public text." ) - def _reconcile(self, parent, models) -> None: + def _reconcile(self, parent: TreeNode[ContextNode], models: tuple[ContextNode, ...]) -> None: previous = {node.data.key: node for node in parent.children} for index, model in enumerate(models): node = previous.pop(model.key, None) @@ -150,7 +155,7 @@ def _reconcile(self, parent, models) -> None: for node in previous.values(): self._retire(node) - def _retire(self, node) -> None: + def _retire(self, node: TreeNode[ContextNode]) -> None: for child in tuple(node.children): self._retire(child) if self.owns_node(node): @@ -161,7 +166,7 @@ def restore_detail(self, placeholder: str) -> None: if not self.intent.restore_detail(self.resolve_reader, self.show_detail): self.show_placeholder(placeholder) - def resolve_reader(self, key): + def resolve_reader(self, key: str): """Rebind the detail source without changing native navigation.""" node = self.context_nodes.get(key) if node is None: @@ -176,7 +181,7 @@ def resolve_reader(self, key): break return node - def _add(self, parent: TreeNode[ContextNode], model, *, before=None): + def _add(self, parent: TreeNode[ContextNode], model: ContextNode, *, before=None): node = parent.add( model.label, model, @@ -196,7 +201,7 @@ def owns_node(self, node): return False return self.is_attached and self.context_nodes.get(node.data.key) is node - def _expand(self, node) -> None: + def _expand(self, node: TreeNode[ContextNode]) -> None: if not self.owns_node(node) or node.children: return # TreeNode owns whether this native disclosure still admits expansion. @@ -205,7 +210,7 @@ def _expand(self, node) -> None: self._add(node, model) node.allow_expand = bool(node.children) - def _toggle_node(self, node) -> None: + def _toggle_node(self, node: TreeNode[ContextNode]) -> None: # Capture disclosure navigation before collapsing a selected descendant. # Data-less groups are native rows too; they don't select detail sources. with self.prevent(Tree.NodeHighlighted): @@ -213,17 +218,17 @@ def _toggle_node(self, node) -> None: super()._toggle_node(node) @on(Tree.NodeExpanded, "#context-tree") - def node_expanded(self, event) -> None: + def node_expanded(self, event: Tree.NodeExpanded[ContextNode]) -> None: if self.owns_node(event.node): self.intent.expanded.add(event.node.data.key) self._expand(event.node) @on(Tree.NodeCollapsed, "#context-tree") - def node_collapsed(self, event) -> None: + def node_collapsed(self, event: Tree.NodeCollapsed[ContextNode]) -> None: if self.owns_node(event.node): self.intent.expanded.discard(event.node.data.key) - def selected(self, model): + def selected(self, model: ContextNode): node = self.context_nodes.get(model.key) return ( self.owns_node(node) @@ -322,7 +327,7 @@ def _observed(self, _value=None) -> None: self._read() @on(Worker.StateChanged) - def context_read_finished(self, event) -> None: + def context_read_finished(self, event: Worker.StateChanged) -> None: if ( event.worker.node is self and event.worker.group == "context-read" @@ -394,7 +399,7 @@ async def _read_owner(self, identity: ThreadIdentity, *, force: bool=False): except asyncio.CancelledError: raise - def _inspection_acquired(self, previous: InspectionState, current, force: bool) -> None: + def _inspection_acquired(self, previous: InspectionState, current: HoldingInspection, force: bool) -> None: self.query_one("#context-older", Button).disabled = ( not current.inspection.page.has_older() ) @@ -439,7 +444,7 @@ def _show_placeholder(self, placeholder: str) -> None: detail.load_text_prepared(placeholder) @on(Button.Pressed, "#context-correct") - def action_correct(self, event) -> None: + def action_correct(self, event: Button.Pressed) -> None: from functools import partial from toad.widgets.comms_menu import show_target_menu @@ -465,7 +470,7 @@ def action_correct(self, event) -> None: ) @work(group="context-correction", exclusive=True) - async def _correct_annotation(self, model, answer) -> None: + async def _correct_annotation(self, model: ContextNode, answer: type[SpanAnswer]) -> None: if not self._selected(model): return worktree = self.query_ancestor(SessionScope).project_root @@ -495,20 +500,20 @@ async def _present(self, captured: HoldingInspection) -> None: self.query_one(ContextTree).present(groups) @on(Button.Pressed, "#context-older") - def older_requests(self, event) -> None: + def older_requests(self, event: Button.Pressed) -> None: event.stop() self.state.present(self._older_requests) - def _older_requests(self, current) -> None: + def _older_requests(self, current: HoldingInspection) -> None: if self.intent.select_page(current.inspection.page.older): self._change_page() @on(Button.Pressed, "#context-newer") - def newer_requests(self, event) -> None: + def newer_requests(self, event: Button.Pressed) -> None: event.stop() self.state.present(self._newer_requests) - def _newer_requests(self, current) -> None: + def _newer_requests(self, current: HoldingInspection) -> None: if self.intent.select_page(current.inspection.page.newer): self._change_page() @@ -528,13 +533,13 @@ def _change_page(self) -> None: self._read() @on(Input.Changed, "#context-search") - def query_changed(self, event) -> None: + def query_changed(self, event: Input.Changed) -> None: event.stop() self.intent.query = event.value @on(Input.Submitted, "#context-search") @on(Button.Pressed, "#context-find") - def action_search(self, event) -> None: + def action_search(self, event: Message) -> None: event.stop() self.state.present(self._present) @@ -555,7 +560,7 @@ async def _search(self, captured: HoldingInspection, query: str, selected: Conte f"{len(matches)} matching sources · first 100 shown · {description}" ) - def _searching(self, captured: HoldingInspection, query, selected): + def _searching(self, captured: HoldingInspection, query: str, selected): return ( self._reading(captured.thread_identity) and self.state.presentation_current(captured) @@ -606,7 +611,7 @@ async def action_export(self) -> None: self.notify(f"Exported selected public text and source to {destination}") @on(Tree.NodeHighlighted, "#context-tree") - def node_highlighted(self, event) -> None: + def node_highlighted(self, event: Tree.NodeHighlighted[ContextNode]) -> None: event.stop() if self.query_one(ContextTree).owns_node(event.node): if self.intent.selected is not event.node.data: @@ -614,13 +619,13 @@ def node_highlighted(self, event) -> None: self.intent.selected = event.node.data self._show_detail(event.node.data) - def _selected(self, model): + def _selected(self, model: ContextNode): 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) -> None: + async def _show_detail(self, model: ContextNode) -> None: # Publication belongs to this exact selected original tree model, not # a reusable string key shared by another thread or context snapshot. if not self._selected(model): diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index e33298150..94b50c080 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -39,6 +39,7 @@ PreparedTranscriptPage, SavedHistory, SourceObservation, + ReplaceablePageSource, TranscriptPageWork, Unchanged, ) @@ -57,6 +58,13 @@ if TYPE_CHECKING: from agent_comms.read_basis import NamedViewer + from toad.comms_root import CoordinationAccess, Observation + from toad.core.input_events import UserInputSubmitted + from toad.work_preparation import PreparationRuntime, WorkKey + from toad.widgets.comms_chat import CommsChatView + from toad.widgets.transcript_history import TranscriptPageView + from toad.widgets.wire_history import WireHistory + from toad.widgets.transcript_lines import Lines @@ -178,7 +186,7 @@ class WireFragment(TranscriptFragment): evidence: MessagePage | None checks_notifications: ClassVar[bool] = False - def _unhandled_message(message, **fields): + def _unhandled_message(message: Message, **fields): raise TypeError(f"No fragment declared for {type(message.role).__name__}") _roles = singledispatch(_unhandled_message) @@ -193,7 +201,7 @@ def of(cls, message: Message, *, reader: str, **fields) -> WireFragment: return cls._roles.dispatch(type(message.role))(message, reader=reader, **fields) @classmethod - def from_message(cls, message, *, reader, **fields): + def from_message(cls, message: Message, *, reader: str, **fields): return cls((IncomingTranscript(message=message, reader=reader),), **fields) @property @@ -235,7 +243,7 @@ def text_rows(self, lines: Lines) -> range: class MembershipFragment(WireFragment, roles=(Membership,)): """Someone joined or left: one muted line.""" - def text(self, shown): + def text(self, shown: MessageShown): return (Body("— " + self.message.body, role=MutedRole),) @@ -244,7 +252,7 @@ class MessageFragment(BusMessageFragment, WireFragment, roles=(ConversationRole, """A sent message: its header, text and the state of its notifications.""" @classmethod - def from_message(cls, message, *, reader, **fields): + def from_message(cls, message: Message, *, reader: str, **fields): return cls((IncomingTranscript(message=message, reader=reader),), text_part=message.body, row=MessageRow(message, reader), **fields) @@ -343,13 +351,13 @@ class WireObservation(SourceObservation): class ReplacedWireObservation(WireObservation): - async def apply(self, history, source) -> bool: + async def apply(self, history: WireHistory, source: ReplaceablePageSource) -> bool: return await history.show_newest(ObservedViewportRequest( LatestPosition.hold(history.window), self)) class GrowingWireObservation(WireObservation): - async def apply(self, history, source) -> bool: + async def apply(self, history: WireHistory, source) -> bool: if not history.undrawn: published = await history.extend(self.through) else: @@ -368,7 +376,7 @@ class WirePageWork(TranscriptPageWork[WireCut]): def retain_result(self) -> bool: return self.request.cursor.kept - async def execute(self, runtime, key): + async def execute(self, runtime: PreparationRuntime, key: WorkKey): import asyncio prepared = await runtime.run_thread(self.loader.read, self.request, self.through) @@ -389,7 +397,7 @@ def __init__(self, *args, revision: ConversationObservation, **kwargs): def replacement(self, through) -> WirePageBuffer: return type(self)(self.loader, through, self.runtime, revision=self.revision) - def uses_observation(self, observation) -> bool: + def uses_observation(self, observation: Observation) -> bool: return observation.uses(self.loader.comms) async def route_is_current(self) -> bool: @@ -397,10 +405,10 @@ async def route_is_current(self) -> bool: return not self.closed and await self.runtime.run_thread(root_is_current, self.loader.comms.root) - def status_thread(self, access): + def status_thread(self, access: CoordinationAccess): return self.loader.conversation.status_thread(self.loader.comms.root, access) - def command_context(self, view): + def command_context(self, view: CommsChatView): from toad.session_scope import SessionScope from toad.target_commands import TargetContext @@ -408,7 +416,7 @@ def command_context(self, view): return TargetContext(view.app, self.loader.comms, self.loader.conversation.destination(references), self.loader.me, self.loader.project, view.query_ancestor(SessionScope).id) - async def submit_input(self, view, event) -> None: + async def submit_input(self, view: CommsChatView, event: UserInputSubmitted) -> None: conversation = self.loader.conversation current = lambda: (view.is_attached and self is view.message_history.page_source and conversation == view.conversation) @@ -416,14 +424,14 @@ async def submit_input(self, view, event) -> None: self.loader.comms.root, conversation, event.body, current, partial(view._show_sent, body=event.body)) - def schedule_refresh(self, view): + def schedule_refresh(self, view: CommsChatView): history = view.message_history return history.schedule_source_work(view._refresh_source) - def painted_read(self, history) -> PaintedRead: + def painted_read(self, history: WireHistory) -> PaintedRead: return ReadWork(history._mark_read(history.source_snapshot())) - async def change_style(self, history) -> None: + async def change_style(self, history: WireHistory) -> None: if not history.can_draw_on_screen: return await history.show_newest(RestyledViewportRequest( @@ -432,10 +440,10 @@ async def change_style(self, history) -> None: async def read_revision(self): return await self.runtime.run_thread(self.loader.comms.views.revision) - async def observe_changes(self, revision): + async def observe_changes(self, revision: ConversationObservation): return await self.runtime.run_thread(self.loader.observe, revision) - async def accept_page(self, history, edge, prepared) -> bool: + async def accept_page(self, history, edge: TranscriptPageView, prepared: PreparedTranscriptPage) -> bool: if edge.prepared.page.accepts(prepared.page.source, self.loader.conversation.history_view): return True prepared.configure(batch_size=len(prepared.fragments)) @@ -443,10 +451,10 @@ async def accept_page(self, history, edge, prepared) -> bool: arrivals=history._source_state.arriving(prepared, history.drawn_tail_followed)) return False - def seen_page(self, history, seen): + def seen_page(self, history: WireHistory, seen): return self.loader.conversation.seen_page(history, seen) - async def mark_page(self, page) -> None: + async def mark_page(self, page: MessagePage) -> None: from agent_comms.route_selection import run_selected_write comms = self.loader.comms @@ -458,13 +466,13 @@ async def read_only(self) -> bool: return await self.runtime.run_thread( self.loader.conversation.read_only, self.loader.comms) - async def update_mentions(self, view) -> None: + async def update_mentions(self, view: CommsChatView) -> None: await self.loader.conversation.update_mentions(view, self.loader.comms) - async def status_text(self, *, read_only): + async def status_text(self, *, read_only: bool): return await self.loader.conversation.status_text(self.loader.comms, read_only=read_only) - def accepts(self, loader, through) -> bool: + def accepts(self, loader: WireSource, through) -> bool: # Earlier wire cuts stay valid while the bus grows. return self.loader is loader From 25b387487cee8696765d0e6e616fdc13dfc6ee7c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:38:12 -0400 Subject: [PATCH 06/10] Declare the ShellTerminal returned by its conversation factory --- src/toad/widgets/conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c82ae81e1..0e7e15172 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1813,7 +1813,7 @@ async def prune_window(self, low_mark: int, high_mark: int) -> None: self.call_later(self.window.anchor) - async def new_terminal(self) -> Terminal: + async def new_terminal(self) -> ShellTerminal: """Create a new interactive Terminal. Args: From 542f851d0863c16c628658f8c8c9805437c7bb20 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:40:17 -0400 Subject: [PATCH 07/10] Declare existing configuration and submission capabilities on AgentConnection --- src/toad/agent_binding.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 805835989..10baf404f 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -12,22 +12,25 @@ from toad.acp.agent_configuration import AgentConfiguration from toad.acp.agent_session import UnopenedSessionDisposition -from toad.agent_presentation import LocalAgentPresentation +from toad.agent_presentation import AgentPresentation, LocalAgentPresentation if TYPE_CHECKING: from agent_comms.goal_actions import GoalAction from agent_comms.session_load import ExistingSessionLoadPlan - from toad.conversation_submission import AgentInputSubmission, ConversationSubmissions + from toad.conversation_submission import AgentInputSubmission, ConversationSubmissions, SubmissionStart from toad.conversation_turn import TurnOwner from toad.core_event_carrier import CoreEventMessage from toad.screens.main import MainScreen from toad.session_observation import SessionObservation from toad.session_presentation import OperationalSessionSources - from toad.widgets.conversation import Conversation + from toad.widgets.conversation import Conversation, ConversationSessionBinding class AgentConnection(ABC): + configuration: AgentConfiguration + presentation: AgentPresentation + @abstractmethod async def observe_thread_presentation(self, presentation: ThreadPresentation | None) -> None: ... @@ -44,26 +47,34 @@ def model_info(self, session_name: str) -> Content: return Content(model.name + (f" · {level}" if level else "")) @abstractmethod - def attach_view(self, view: Conversation) -> None: ... + def attach_view(self, view: ConversationSessionBinding) -> None: ... @abstractmethod - def refresh_projection(self, view: Conversation) -> None: ... + def refresh_projection(self, view: ConversationSessionBinding) -> None: ... @abstractmethod - def start_session(self, view: Conversation) -> None: ... + def start_session(self, view: ConversationSessionBinding) -> None: ... @abstractmethod def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: ... @abstractmethod - def update_view(self, view: Conversation) -> None: ... + def update_view(self, view: ConversationSessionBinding) -> None: ... @abstractmethod - def connected(self, view: Conversation) -> None: ... + def connected(self, view: ConversationSessionBinding) -> None: ... @abstractmethod def accepts_message(self, message: CoreEventMessage) -> bool: ... + @abstractmethod + def begin_submission( + self, owner: ConversationSubmissions, submission: AgentInputSubmission + ) -> SubmissionStart: ... + + @abstractmethod + def accepts_failed_input(self, message: CoreEventMessage) -> bool: ... + @abstractmethod def matches_agent(self, agent) -> bool: ... @@ -91,22 +102,22 @@ def get_info(self) -> str: async def retire_surface(self, view: Conversation) -> None: """There is no native surface to retire.""" - def attach_view(self, view: Conversation) -> None: + def attach_view(self, view: ConversationSessionBinding) -> None: """There is no event stream to subscribe to.""" - def refresh_projection(self, view: Conversation) -> None: + def refresh_projection(self, view: ConversationSessionBinding) -> None: """There is no native projection to read.""" - def start_session(self, view: Conversation) -> None: + def start_session(self, view: ConversationSessionBinding) -> None: view.start_unbound_session() def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: sources.create_agent(view) - def update_view(self, view: Conversation) -> None: + def update_view(self, view: ConversationSessionBinding) -> None: view.agent_info = Content.styled("shell") - def connected(self, view: Conversation) -> None: + def connected(self, view: ConversationSessionBinding) -> None: """There is no connected-agent notification.""" async def set_session_name(self, name: str) -> None: From 6d384a3ca2ff7d6f68971ba769df0fb9000340ef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:41:36 -0400 Subject: [PATCH 08/10] Leave unresolved source correlations as explicit annotation stops --- src/toad/acp/agent_controller.py | 129 +++++++++++++++++---------- src/toad/acp/prompt.py | 11 ++- src/toad/agent_binding.py | 14 +-- src/toad/agent_surface.py | 64 ++++++++----- src/toad/conversation_kind.py | 2 +- src/toad/conversation_submission.py | 85 ++++++++++++------ src/toad/frame_presentation.py | 40 ++++++--- src/toad/widgets/context_explorer.py | 103 ++++++--------------- src/toad/wire_history.py | 126 +++++++++++++++----------- 9 files changed, 331 insertions(+), 243 deletions(-) diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index 3ce351a53..1916a40da 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -1,4 +1,5 @@ """ACP operations remain owned by the controller after their view retires.""" + from __future__ import annotations import asyncio @@ -34,7 +35,6 @@ from .terminal_owner import OperationalTerminalOwner from .transcript_reader import CoordinationTranscriptReader - if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Coroutine from pathlib import Path @@ -51,6 +51,7 @@ @dataclass(frozen=True) class SessionBinding: """Actual ACP binding identity, preserved only until session replacement.""" + session_id: str | None @property @@ -60,12 +61,12 @@ def bound(self) -> bool: def accepts_notification(self, session_id: str): return self.bound and self.session_id == session_id - def accepts_snapshot(self, identity: TranscriptReadIdentity, - coordination: CoordinationChangedUpdate | None) -> bool: + def accepts_snapshot( + self, identity: TranscriptReadIdentity, coordination: CoordinationChangedUpdate | None + ) -> bool: if not self.accepts_notification(identity.requested_name) or coordination is None: return False - return (identity.root == coordination.wire_root - and identity.thread.incarnation == coordination.thread) + return identity.root == coordination.wire_root and identity.thread.incarnation == coordination.thread class TaskRunner(ABC): @@ -88,6 +89,7 @@ async def run[Result](self, task: RenderTask[Result]) -> Result: class AgentController(OperationalTerminalOwner): """One operational source; the surface is an optional weak projection.""" + def __init__(self, agent: Agent, transcripts: CoordinationTranscriptReader) -> None: super().__init__(agent) self.surface: SurfaceBinding = DetachedSurfaceBinding() @@ -132,6 +134,7 @@ def detach(self, target) -> None: async def restore(self, binding: SurfaceBinding) -> None: from toad.core.events import AvailableCommandsUpdate, CommsUpdated + if self.surface is not binding: return agent = self.agent @@ -142,6 +145,7 @@ async def restore(self, binding: SurfaceBinding) -> None: agent.events.publish(AvailableCommandsUpdate()) if self.plan_entries is not None: from toad.core.events import Plan + agent.events.publish(Plan(self.plan_entries)) agent._post_queue_view() agent._post_private_cursor() @@ -150,8 +154,6 @@ async def restore(self, binding: SurfaceBinding) -> None: self.agent.events.publish(CommsUpdated(agent.coordination, agent.session_id)) binding.schedule_terminal_presentation(self) - - async def publish_transcript_snapshot( self, update: TranscriptSnapshotUpdate, binding: SurfaceBinding, session: SessionBinding ) -> None: @@ -168,6 +170,7 @@ async def publish_transcript_snapshot( def connection_closed(self) -> None: from toad.core.events import McpClientStopped + agent = self.agent agent.session.closed() agent.permissions.cancel() @@ -182,11 +185,13 @@ def reset_configuration(self) -> None: def publish_plan(self, entries: list[PlanItem]) -> None: """Keep the latest typed source value while its optional view is absent.""" from toad.core.events import Plan + self.plan_entries = entries self.agent.events.publish(Plan(entries)) def publish_commands(self, commands: list[AvailableCommand]) -> None: from toad.core.events import AvailableCommandsUpdate + self.commands = commands self.agent.events.publish(AvailableCommandsUpdate()) @@ -200,9 +205,14 @@ async def submit(self, prompt: str, *, request: PromptRequest | None = None): agent = self.agent command = request if request is not None else QueuePromptRequest(prompt) authority = ClientSessionRequest(agent, agent.session_id) - return await self.operate(self._prompt_operation(partial(self._submit, - prompt, command, authority, agent.queue_attachment.scope, - agent.project_root_path), authority)) + return await self.operate( + self._prompt_operation( + partial( + self._submit, prompt, command, authority, agent.queue_attachment.scope, agent.project_root_path + ), + authority, + ) + ) async def _prompt_operation[Result]( self, operation: Callable[[], Awaitable[Result]], authority: ClientSessionRequest @@ -224,8 +234,7 @@ async def reconcile_turn(self, coordination: CoordinationChangedUpdate, authorit def accepts_turn_snapshot(self, sequence: int): """A remote busy turn does not imply an ordered response on this client.""" - return (self.prompt_in_flight == 0 - and sequence == self.agent.presentation.turns.sequence) + return self.prompt_in_flight == 0 and sequence == self.agent.presentation.turns.sequence async def _submit( self, @@ -243,26 +252,28 @@ async def _submit( if coordination is not None: self.require_owner(coordination, authority) comms = await self.transcripts.service(coordination.wire_root) - origin = await asyncio.to_thread( - self.agent.queue_attachment.capture_human_input, comms, queue_scope) + origin = await asyncio.to_thread(self.agent.queue_attachment.capture_human_input, comms, queue_scope) self.require_owner(coordination, authority) command = replace(command, origin=origin) content = await asyncio.to_thread(build_prompt, project, prompt) - if any(block.type == 'image' for block in content): + if any(block.type == "image" for block in content): coordinated = self.coordination is not None supported = coordinated or self.agent.session.supports_images if not supported: - raise ValueError('This agent owner does not support images yet; refresh it while idle.') + raise ValueError("This agent owner does not support images yet; refresh it while idle.") return await self._prompt(content, command, authority, queue_scope) finally: if submission is not None: self._deferred_submissions.discard(submission) - async def submit_blocks(self, content: list[dict[str, str]], command: ClearQueueRequest | None=None): + async def submit_blocks(self, content: list[dict[str, str]], command: ClearQueueRequest | None = None): agent = self.agent authority = ClientSessionRequest(agent, agent.session_id) - return await self.operate(self._prompt_operation(partial(self._prompt, - content, command, authority, agent.queue_attachment.scope), authority)) + return await self.operate( + self._prompt_operation( + partial(self._prompt, content, command, authority, agent.queue_attachment.scope), authority + ) + ) async def _prompt(self, content, command, authority: ClientSessionRequest, queue_scope: QueueScope): agent = self.agent @@ -270,19 +281,19 @@ async def _prompt(self, content, command, authority: ClientSessionRequest, queue def require(): authority.require() if not agent.queue_attachment.accepts_request(queue_scope): - raise ValueError('The queued input owner changed before submission; inspect the current queue.') + raise ValueError("The queued input owner changed before submission; inspect the current queue.") try: - result = await agent.process.prompt(agent, content, - encode_request(command) if command is not None else {}, require) + result = await agent.process.prompt( + agent, content, encode_request(command) if command is not None else {}, require + ) except RequestError as error: # The agent answered: this request failed and its session stays open. request_failure = RequestFailure.from_error(error) report = agent.events.publish except OSError as error: # The transport is gone, and the session with it. - request_failure = RequestFailure( - ACPFailure.from_detail(None, f"{type(error).__name__}: {error}"), False) + request_failure = RequestFailure(ACPFailure.from_detail(None, f"{type(error).__name__}: {error}"), False) report = agent.session.record_failure else: if authority.retired: @@ -291,8 +302,9 @@ def require(): if authority.retired: return None failure = request_failure.failure - self._prompt_failed(command, authority, queue_scope, failure, report, - published=request_failure.notification_published) + self._prompt_failed( + command, authority, queue_scope, failure, report, published=request_failure.notification_published + ) return None def _prompt_failed( @@ -308,44 +320,61 @@ def _prompt_failed( agent = self.agent user_text = command.draft_text if command is not None else None if user_text: - agent.events.publish(core_events.CommsUpdated(InputFailedUpdate(user_text, failure), recover_draft=True, session_id=authority.session_id, queue_scope=queue_scope)) + agent.events.publish( + core_events.CommsUpdated( + InputFailedUpdate(user_text, failure), + recover_draft=True, + session_id=authority.session_id, + queue_scope=queue_scope, + ) + ) if not published: report(LogAgentFail(failure.title, failure.feedback, log_path=agent.presentation.log_path)) async def clear_queue(self) -> None: - await self.submit_blocks([{'type': 'text', 'text': ' '}], ClearQueueRequest()) + await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest()) async def send_now(self) -> bool: authority = ClientSessionRequest(self.agent, self.agent.session_id) queue_scope = self.agent.queue_attachment.scope if pending := tuple(self._deferred_submissions): await asyncio.gather(*(asyncio.shield(task) for task in pending)) - return await self.operate(self._prompt_operation(partial(self._prompt, - [{'type': 'text', 'text': ' '}], SendNowRequest(), authority, queue_scope), authority)) is not None + return ( + await self.operate( + self._prompt_operation( + partial(self._prompt, [{"type": "text", "text": " "}], SendNowRequest(), authority, queue_scope), + authority, + ) + ) + is not None + ) async def compact_context(self, instructions=None): authority = ClientSessionRequest(self.agent, self.agent.session_id) - return await self.operate(self._prompt_operation(partial(self._compact_context, - instructions, authority), authority)) + return await self.operate( + self._prompt_operation(partial(self._compact_context, instructions, authority), authority) + ) async def _compact_context(self, instructions, authority: ClientSessionRequest): agent = self.agent try: - response = await agent.process.prompt(agent, [{'type': 'text', 'text': ' '}], - encode_request(CompactRequest(instructions)), authority.require) + response = await agent.process.prompt( + agent, [{"type": "text", "text": " "}], encode_request(CompactRequest(instructions)), authority.require + ) except RequestError as error: failure = ACPFailure.from_error(error) - raise ValueError(f'{failure.title}: {failure.detail}\n{failure.input_disposition}\n{failure.action}') from error + raise ValueError( + f"{failure.title}: {failure.detail}\n{failure.input_disposition}\n{failure.action}" + ) from error authority.require() if response is None: - raise ValueError('Compaction returned no result') + raise ValueError("Compaction returned no result") consumer = agent.comms_consumer_class(agent, authority.session_id) await consumer.consume_metadata(response.field_meta) return consumer.require_compaction_update() async def cancel_prompt(self): - return await self.operate(self._cancel_prompt( - ClientSessionRequest(self.agent, self.agent.session_id))) + return await self.operate(self._cancel_prompt(ClientSessionRequest(self.agent, self.agent.session_id))) async def _cancel_prompt(self, authority: ClientSessionRequest): authority.require() @@ -357,19 +386,24 @@ async def _cancel_prompt(self, authority: ClientSessionRequest): async def request_owner(self, method: str, **params: JsonValue): if self.coordination is None: - raise ValueError('This action requires an agent-comms thread.') + raise ValueError("This action requires an agent-comms thread.") from agent_comms.runtime import RuntimeProxy, socket_path from toad.owner_preparation import OwnerRequestContext + coordination = self.coordination authority = ClientSessionRequest(self.agent, self.agent.session_id) self.require_owner(coordination, authority) comms = await self.transcripts.service(coordination.wire_root) + def resolve(): owner = comms.registry.require(coordination.thread.name) if owner.incarnation != coordination.thread: - raise ValueError('The owner incarnation changed while preparing the request.') - return RuntimeProxy(OwnerRequestContext(comms), owner.name, socket_path(comms.root, owner.require_process().pid)) + raise ValueError("The owner incarnation changed while preparing the request.") + return RuntimeProxy( + OwnerRequestContext(comms), owner.name, socket_path(comms.root, owner.require_process().pid) + ) + proxy = await asyncio.to_thread(resolve) try: self.require_owner(coordination, authority) @@ -383,20 +417,21 @@ def resolve(): def require_owner(self, coordination: CoordinationChangedUpdate, authority: ClientSessionRequest): if authority.retired or self.coordination is None: - raise ValueError('The connected owner session retired; refresh its state.') + raise ValueError("The connected owner session retired; refresh its state.") if (self.coordination.wire_root, self.coordination.thread) != (coordination.wire_root, coordination.thread): - raise ValueError('The owner identity changed while preparing the request.') + raise ValueError("The owner identity changed while preparing the request.") - async def input_delivery(self, *, include_history: bool=False) -> InputDelivery: + async def input_delivery(self, *, include_history: bool = False) -> InputDelivery: """The owner's delivery notices, read by the observation service.""" coordination = self.coordination if coordination is None: return InputDelivery() authority = ClientSessionRequest(self.agent, self.agent.session_id) - delivery = await self.transcripts.read(ReadInputDelivery( - coordination.wire_root, coordination.thread, include_history=include_history)) + delivery = await self.transcripts.read( + ReadInputDelivery(coordination.wire_root, coordination.thread, include_history=include_history) + ) self.require_owner(coordination, authority) return delivery async def dismiss_historical_inputs(self): - return await self.request_owner('dismiss_historical_inputs') + return await self.request_owner("dismiss_historical_inputs") diff --git a/src/toad/acp/prompt.py b/src/toad/acp/prompt.py index 7d2c5240e..fc733ff55 100644 --- a/src/toad/acp/prompt.py +++ b/src/toad/acp/prompt.py @@ -3,11 +3,12 @@ from acp import schema from acp.helpers import ContentBlock +from agent_comms.image_inputs import ImageInput, prompt_images + +from toad.clipboard_image import attachment_directory from toad.file_kind import FileKind from toad.prompt.extract import extract_paths_from_prompt -from toad.prompt.resource import load_resource, ResourceError -from toad.clipboard_image import attachment_directory -from agent_comms.image_inputs import ImageInput, prompt_images +from toad.prompt.resource import ResourceError, load_resource def build(project_path: Path, prompt: str) -> list[ContentBlock]: @@ -69,4 +70,6 @@ def build(project_path: Path, prompt: str) -> list[ContentBlock]: if image_spans: prompt_content[0]["text"] = prompt.strip() prompt_images(prompt_content) # Validate the combined image count/size too. - return schema.PromptRequest.model_validate({"sessionId": "attachments", "prompt": prompt_content}, strict=True).prompt + return schema.PromptRequest.model_validate( + {"sessionId": "attachments", "prompt": prompt_content}, strict=True + ).prompt diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 10baf404f..312744e11 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -18,7 +18,11 @@ from agent_comms.goal_actions import GoalAction from agent_comms.session_load import ExistingSessionLoadPlan - from toad.conversation_submission import AgentInputSubmission, ConversationSubmissions, SubmissionStart + from toad.conversation_submission import ( + AgentInputSubmission, + ConversationSubmissions, + SubmissionStart, + ) from toad.conversation_turn import TurnOwner from toad.core_event_carrier import CoreEventMessage from toad.screens.main import MainScreen @@ -32,7 +36,7 @@ class AgentConnection(ABC): presentation: AgentPresentation @abstractmethod - async def observe_thread_presentation(self, presentation: ThreadPresentation | None) -> None: ... + async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: ... def thread_read(self): from toad.acp.comms_updates import NoPendingThreadRead @@ -68,9 +72,7 @@ def connected(self, view: ConversationSessionBinding) -> None: ... def accepts_message(self, message: CoreEventMessage) -> bool: ... @abstractmethod - def begin_submission( - self, owner: ConversationSubmissions, submission: AgentInputSubmission - ) -> SubmissionStart: ... + def begin_submission(self, owner: ConversationSubmissions, submission: AgentInputSubmission) -> SubmissionStart: ... @abstractmethod def accepts_failed_input(self, message: CoreEventMessage) -> bool: ... @@ -189,7 +191,7 @@ async def change_project(self, view: Conversation, path: Path) -> None: def request_compaction(self, view: Conversation, instructions) -> None: view.feedback.flash("Context compaction requires an agent-comms session", style="error") - async def observe_thread_presentation(self, presentation) -> None: + async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: """There is no connected agent to inform.""" def invalidate_observation(self, observation: SessionObservation) -> None: diff --git a/src/toad/agent_surface.py b/src/toad/agent_surface.py index 0d01f9bb0..b7ab60404 100644 --- a/src/toad/agent_surface.py +++ b/src/toad/agent_surface.py @@ -1,15 +1,19 @@ """Textual attachment borrows original agent services and projection resources.""" -from weakref import ref from contextlib import AsyncExitStack from functools import partial from typing import TYPE_CHECKING +from weakref import ref from agent_comms.mro_dispatch import MroDispatch, handles -from toad.surface_binding import SurfaceBinding + from toad.acp.agent_controller import ProcessTaskRunner -from toad.permission_presentation import DiffPermissionPresentation, InlinePermissionPresentation +from toad.permission_presentation import ( + DiffPermissionPresentation, + InlinePermissionPresentation, +) from toad.shell_output import ShellCommandOutput, ShellTerminalOutput +from toad.surface_binding import SurfaceBinding if TYPE_CHECKING: from textual.message import Message @@ -38,7 +42,7 @@ def prepare(self, controller: AgentController) -> None: app = self.target.app controller.task_runner = ProcessTaskRunner(app.render_processes) - def owns_publication(self, message: CoreEventMessage, target: Conversation): + def owns_publication(self, message, target: Conversation): if not self.subscription.accepts(message.subscription): return False if not self.owns(target) or not target.agent_binding.matches_agent(message.publisher): @@ -48,16 +52,19 @@ def owns_publication(self, message: CoreEventMessage, target: Conversation): return target.is_attached and message.publisher.tools.presentation(message.event) is not None async def _post_native( - self, message: CoreEventMessage, target: Conversation, widget: Widget, retirement: AsyncExitStack, + self, + message: CoreEventMessage, + target: Conversation, + widget: Widget, + retirement: AsyncExitStack, ): if not self.owns_publication(message, target): return False - retirement.push_async_callback(target.transcript.retire_presentations, - target.contents, target.output, [widget]) + retirement.push_async_callback(target.transcript.retire_presentations, target.contents, target.output, [widget]) await target.post(widget, new_block=True) return self.owns_publication(message, target) and widget.is_attached - async def present_tool(self, message: CoreEventMessage, target: Conversation): + async def present_tool(self, message, target: Conversation): from toad.tool_output import ToolOutput from toad.widgets.outgoing_message import OutgoingMessage from toad.widgets.tool_call import ToolCall @@ -69,7 +76,9 @@ async def present_tool(self, message: CoreEventMessage, target: Conversation): status = event.tool_call status.activity(target.turns, target.output, status.title) parts = await target.app.preparation.run_thread( - ToolOutput.capture_projection, status, event.scope, + ToolOutput.capture_projection, + status, + event.scope, ) if not self.owns_publication(message, target): return @@ -118,8 +127,7 @@ async def present_shell(self, output: ShellOutput) -> None: async def show_shell_command(self, output: ShellCommandOutput, target: Conversation): from toad.widgets.shell_result import ShellResult - if not any(isinstance(child, ShellResult) and child.source is output - for child in target.contents.children): + if not any(isinstance(child, ShellResult) and child.source is output for child in target.contents.children): await target.post(ShellResult(output)) @handles(ShellTerminalOutput) @@ -127,8 +135,9 @@ async def show_shell_terminal(self, output: ShellTerminalOutput, target: Convers if output.terminal is None: from toad.widgets.shell_terminal import ShellTerminal - terminal = next((terminal for terminal in target.query(ShellTerminal) - if terminal.state is output.state), None) + terminal = next( + (terminal for terminal in target.query(ShellTerminal) if terminal.state is output.state), None + ) if terminal is None: terminal = await target.new_terminal() output.attach(terminal) @@ -145,7 +154,10 @@ def permission_changed(self, view: Conversation) -> None: @handles(DiffPermissionPresentation) async def show_file_permission( - self, presentation: DiffPermissionPresentation, view: Conversation, request: PermissionRequest, + self, + presentation: DiffPermissionPresentation, + view: Conversation, + request: PermissionRequest, ): from toad.screens.permissions import PermissionReview @@ -155,8 +167,9 @@ async def show_file_permission( return try: app.terminal_attention.require(screen) - app.terminal_attention.notify(f"{view.agent_title} would like to write files", - title="Permissions request", sound="question") + app.terminal_attention.notify( + f"{view.agent_title} would like to write files", title="Permissions request", sound="question" + ) result = await app.push_screen_wait(screen, mode=view.screen.id) request.answer(self, result) finally: @@ -164,20 +177,24 @@ async def show_file_permission( @handles(InlinePermissionPresentation) async def show_inline_permission( - self, presentation: InlinePermissionPresentation, view: Conversation, request: PermissionRequest, + self, + presentation: InlinePermissionPresentation, + view: Conversation, + request: PermissionRequest, ): - from toad.widgets.acp_content import ACPToolCallContent from toad.tool_output import decode_content + from toad.widgets.acp_content import ACPToolCallContent - parts = tuple(preview for item in presentation.parts - if (preview := decode_content(item).permission_preview()) is not None) + parts = tuple( + preview for item in presentation.parts if (preview := decode_content(item).permission_preview()) is not None + ) def answer(answer: Answer): request.answer(self, answer) - ask = view.ask(request.options, presentation.title, - partial(ACPToolCallContent, parts) if parts else None, - answer) + ask = view.ask( + request.options, presentation.title, partial(ACPToolCallContent, parts) if parts else None, answer + ) def retire(): if view.is_attached: @@ -208,6 +225,7 @@ def post(self, message: Message): def publish_terminal(self, controller: TerminalController, terminal_id: str, execution: TerminalExecution): from toad.widgets.terminal_tool import TerminalTool + return self.post(TerminalTool.Projection(self, controller, terminal_id, execution)) def owns_terminal(self, projection: TerminalTool.Projection, target: Conversation): diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index dd7c35cbf..3310bc718 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -286,7 +286,7 @@ async def update_mentions(cls, view: CommsChatView, comms: Comms) -> None: """Direct-message composers have no channel mention roster.""" @classmethod - def compose_activity(cls, view: CommsChatView) -> ComposeResult: + def compose_activity(cls, view) -> ComposeResult: view.thread_activity = ConversationSessionDetails(view.conversation.peer) yield view.thread_activity diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 9e439c6e9..bead09844 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -1,4 +1,5 @@ """One submission transaction owns input intent, request identity and draft recovery.""" + from __future__ import annotations import asyncio @@ -7,16 +8,22 @@ from functools import partial from typing import TYPE_CHECKING, ClassVar -from agent_comms.acp_extension import InputStartedUpdate, QueuePromptRequest, QueueScope, SteerPromptRequest +from acp.exceptions import RequestError +from agent_comms.acp_extension import ( + InputStartedUpdate, + PendingQueueProjection, + QueuePromptRequest, + QueueScope, + SteerPromptRequest, +) from agent_comms.declared_family import DeclaredFamily + from toad import messages -from acp.exceptions import RequestError from toad.acp.client_session import ClientSessionRequest +from toad.command_catalog import CommandCatalog from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventMessage -from agent_comms.acp_extension import PendingQueueProjection from toad.widgets.prompt import Prompt -from toad.command_catalog import CommandCatalog if TYPE_CHECKING: from toad.acp.agent import Agent @@ -41,47 +48,55 @@ async def execute(self, owner: ConversationSubmissions): ... @classmethod def from_event(cls, event: UserInputSubmitted, view: Conversation): - kind = next(kind for kind in sorted(cls.members_with(InputSubmission), - key=lambda kind: kind.priority, reverse=True) - if kind.matches(event, view)) + kind = next( + kind + for kind in sorted(cls.members_with(InputSubmission), key=lambda kind: kind.priority, reverse=True) + if kind.matches(event, view) + ) return kind(event.body) class NoInputSubmission(InputSubmission): priority = -1 + @classmethod def matches(cls, event: UserInputSubmitted, view: Conversation): return True + async def execute(self, owner: ConversationSubmissions): pass class QueueNowInputSubmission(InputSubmission): priority = 100 + @classmethod def matches(cls, event: UserInputSubmitted, view: Conversation): if event.shell or event.body.strip(): return False return event.immediate and view.agent_binding.presentation.queue_supported + async def execute(self, owner: ConversationSubmissions): projection = owner.queue_projection - if projection.status == 'available' and projection.items: + if projection.status == "available" and projection.items: owner.send_now() class ShellInputSubmission(InputSubmission): priority = 90 + @classmethod def matches(cls, event: UserInputSubmitted, view: Conversation): return event.shell and bool(event.body.strip()) - async def execute(self, owner: ConversationSubmissions): + + async def execute(self, owner): view = owner.view shell = view.shell if await shell.is_busy(): shell.focus_output() await shell.send_input(self.source_text, paste=True) else: - view.run_worker(partial(view.input_histories.shell.record, self.source_text), group='history') + view.run_worker(partial(view.input_histories.shell.record, self.source_text), group="history") await view.post_shell(self.source_text) view.jump_to_latest() @@ -99,7 +114,8 @@ def __init__(self, owner: ConversationSubmissions, submission: AgentInputSubmiss async def prepare(self): self.owner.view.delivery_refused( - "No connected owner; your input has not been submitted", self.submission.source_text) + "No connected owner; your input has not been submitted", self.submission.source_text + ) class AgentInputSubmission(InputSubmission): @@ -116,7 +132,7 @@ async def feedback(self, execution: SubmissionExecution): await view.auto_name_from_prompt(self.text) if execution.retired: return - waiting = 'Waiting for replies…' if self.text.startswith(('@', '#', '!relay ')) else 'Thinking…' + waiting = "Waiting for replies…" if self.text.startswith(("@", "#", "!relay ")) else "Thinking…" view.turns.describe(waiting) await asyncio.sleep(0) @@ -131,6 +147,7 @@ def matches(cls, event: UserInputSubmitted, view: Conversation): if event.shell: return False return bool(event.body.strip()) + @property def request(self): return QueuePromptRequest(self.text) @@ -138,6 +155,7 @@ def request(self): class DeferredInputSubmission(OrdinaryInputSubmission): priority = 70 + @classmethod def matches(cls, event: UserInputSubmitted, view: Conversation): if not super().matches(event, view): @@ -145,9 +163,11 @@ def matches(cls, event: UserInputSubmitted, view: Conversation): if event.immediate: return False return view.turns.owner.busy and view.agent_binding.presentation.queue_supported + @property def request(self): return QueuePromptRequest(self.text, True) + async def feedback(self, execution: SubmissionExecution): # Native queue/start publications own confirmation and message display. pass @@ -155,17 +175,21 @@ async def feedback(self, execution: SubmissionExecution): class ImmediateInputSubmission(OrdinaryInputSubmission): priority = 80 + @classmethod def matches(cls, event: UserInputSubmitted, view: Conversation): if not super().matches(event, view): return False return event.immediate and view.agent_binding.presentation.queue_supported + @property def request(self): return SteerPromptRequest(self.text) + class SubmissionExecution(SubmissionStart): """Immutable source binding and the real outstanding local request.""" + def __init__(self, owner: ConversationSubmissions, submission: AgentInputSubmission, agent: Agent): self.owner, self.submission, self.agent = owner, submission, agent self.authority = ClientSessionRequest(agent, agent.session_id) @@ -177,11 +201,11 @@ async def prepare(self): owner, submission = self.owner, self.submission view = owner.view owner.publish_pending() - view.run_worker(partial(view.input_histories.prompt.record, submission.source_text), group='history') + view.run_worker(partial(view.input_histories.prompt.record, submission.source_text), group="history") try: await submission.feedback(self) if self.current: - return view.run_worker(partial(self.send), group='conversation-submission') + return view.run_worker(partial(self.send), group="conversation-submission") owner.finish(self) except BaseException: owner.finish(self) @@ -214,10 +238,15 @@ async def send(self): if self.current: from toad.widgets.conversation import INTERNAL_EROR from toad.widgets.markdown_note import MarkdownNote + view.turns.finish_client() view.prompt.restore_draft(self.submission.text) - await view.post(MarkdownNote(INTERNAL_EROR.replace('$ERROR', str(error) or 'no details were provided'), - classes='-stop-reason')) + await view.post( + MarkdownNote( + INTERNAL_EROR.replace("$ERROR", str(error) or "no details were provided"), + classes="-stop-reason", + ) + ) finally: self.owner.finish(self) if self.current and local: @@ -230,7 +259,8 @@ async def complete(self, reason): class ConversationSubmissions: """Own pending local transactions; remote membership stays with QueueAttachment.""" - def __init__(self, view: Conversation): + + def __init__(self, view): self.view = view self.active: list[SubmissionExecution] = [] @@ -255,7 +285,7 @@ def unavailable_reason(self): async def submit(self, event: UserInputSubmitted): text = event.body.strip() - if not event.shell and text.startswith('/') and await CommandCatalog.execute(text, self.view): + if not event.shell and text.startswith("/") and await CommandCatalog.execute(text, self.view): return return await self.view.operational_sources.submit(self, event) @@ -275,10 +305,14 @@ def queued_inputs(self): def delivering(self): queue = self.queue_projection accepted = {row.input_id for row in (*queue.items, *queue.restored)} - return tuple(item.submission.text for item in self.active - if item.current and item.agent.presentation.uses_managed_turns - and item.request.input_id not in accepted - and item.started_update is None) + return tuple( + item.submission.text + for item in self.active + if item.current + and item.agent.presentation.uses_managed_turns + and item.request.input_id not in accepted + and item.started_update is None + ) def native_input_presented(self, started: InputStartedUpdate): """Retain this request's actual response while its RPC is outstanding.""" @@ -305,8 +339,9 @@ def send_now(self): def send_bound_now(self, agent: Agent): session, scope = agent.session_id, agent.queue_attachment.scope - return self.view.run_worker(partial(self._send_now, agent, session, scope), - group='send-queued-now', exclusive=True) + return self.view.run_worker( + partial(self._send_now, agent, session, scope), group="send-queued-now", exclusive=True + ) async def _send_now(self, agent: Agent, session, scope: QueueScope): try: @@ -314,6 +349,6 @@ async def _send_now(self, agent: Agent, session, scope: QueueScope): return except RequestError as error: if self.view.agent_binding is agent and agent.session_id == session: - self.view.feedback.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/frame_presentation.py b/src/toad/frame_presentation.py index e578d1f20..309d4b3c3 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -1,4 +1,5 @@ """Each frame tracks terminal flush completion, readiness and deferred source work.""" + from __future__ import annotations import asyncio @@ -21,8 +22,9 @@ if TYPE_CHECKING: from textual.css.styles import StylesBase - from toad.widgets.transcript_history import TranscriptPageView + from toad.screens.workspace import WorkspaceScreen + from toad.widgets.transcript_history import TranscriptPageView class ToneLayer(PaintLayer): @@ -32,7 +34,15 @@ class ToneLayer(PaintLayer): alpha; with no tone the CSS paint is unchanged. """ - __slots__ = ("styles", "paint_background", "paint_foreground", "auto_color", "layer_opacity", "text_style", "background_tint") + __slots__ = ( + "styles", + "paint_background", + "paint_foreground", + "auto_color", + "layer_opacity", + "text_style", + "background_tint", + ) def __init__(self, styles: StylesBase, tone: Color | None, opacity: float, paint: TonePaint) -> None: self.styles = styles @@ -48,6 +58,7 @@ def __init__(self, styles: StylesBase, tone: Color | None, opacity: float, paint tint = paint.background_tint self.background_tint = styles.background_tint if tint is None else tint self.paint_background = stated if tone is None else tone if stated is None else tone + stated + text_opacity = property(lambda self: self.styles.text_opacity) @@ -65,8 +76,7 @@ def text_background(self) -> type[ThemeColour]: @property def paint_layer(self) -> PaintLayer: tone = self.tone - return ToneLayer(self.styles, None if tone is None else self.app.colours.colour(tone), - self.paint_opacity, self) + return ToneLayer(self.styles, None if tone is None else self.app.colours.colour(tone), self.paint_opacity, self) class UntintedTonePaint(TonePaint): @@ -200,8 +210,7 @@ def defer(self, owner: Widget, callback: Callable[[], object]) -> None: def flush_owner(self, owner: Widget, callback: Callable[[], object]) -> None: """Wait for the sender's frame before waiting for the terminal writer.""" if self.awaits_publication(owner, callback): - self.screen.app._driver.call_after_flush( - partial(self.release, owner, callback, self.state.scene)) + self.screen.app._driver.call_after_flush(partial(self.release, owner, callback, self.state.scene)) def release(self, owner: Widget, callback: Callable[[], object], scene: PendingFrame) -> None: """Release the admitted owner after its publication's writer join.""" @@ -320,8 +329,7 @@ def effect_frame(self, effect: Effect[FrameT]) -> FrameT: def effect_phase(self, effect: Effect[FrameT]) -> EffectPhase: animation = self.effects.get(effect) - return (EFFECT_UNSTARTED if animation is None else - EFFECT_FINISHED if animation.finished else EFFECT_RUNNING) + return EFFECT_UNSTARTED if animation is None else EFFECT_FINISHED if animation.finished else EFFECT_RUNNING async def on_unmount(self) -> None: self.stop_effect() @@ -338,9 +346,17 @@ def on_show(self) -> None: def paint_effects(self, strip: Strip, *frames: Mix) -> Strip: # Background-origin text rises from the widget's tone; accent flashes # keep their declared origin. Both finish at each run's own ink. - for frame in (*frames, *(replace(self.effect_frame(effect), start=self.text_background) - if effect.start is BackgroundColour else self.effect_frame(effect) - for effect in self.text_fades)): + for frame in ( + *frames, + *( + ( + replace(self.effect_frame(effect), start=self.text_background) + if effect.start is BackgroundColour + else self.effect_frame(effect) + ) + for effect in self.text_fades + ), + ): strip = self.app.colours.paint_rising(frame, strip) return strip @@ -351,7 +367,7 @@ def animation_visible(self) -> bool: screen = self.screen try: current = self.app.screen - except (ScreenStackError, UnknownModeError): + except ScreenStackError, UnknownModeError: return False return screen is current and self in screen._compositor.visible_widgets diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 1f0498be8..76172e0ca 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -32,7 +32,6 @@ from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarVisibilityObserver from toad.widgets.thread_identity import ShowsThreadIdentity - if TYPE_CHECKING: from agent_comms.working_memory_questions import SpanAnswer from textual.message import Message @@ -127,11 +126,9 @@ def search_results(self, matches: tuple[ContextNode, ...], description: str, que 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: TreeNode[ContextNode], models: tuple[ContextNode, ...]) -> None: + def _reconcile(self, parent, models: tuple[ContextNode, ...]) -> None: previous = {node.data.key: node for node in parent.children} for index, model in enumerate(models): node = previous.pop(model.key, None) @@ -155,7 +152,7 @@ def _reconcile(self, parent: TreeNode[ContextNode], models: tuple[ContextNode, . for node in previous.values(): self._retire(node) - def _retire(self, node: TreeNode[ContextNode]) -> None: + def _retire(self, node) -> None: for child in tuple(node.children): self._retire(child) if self.owns_node(node): @@ -201,7 +198,7 @@ def owns_node(self, node): return False return self.is_attached and self.context_nodes.get(node.data.key) is node - def _expand(self, node: TreeNode[ContextNode]) -> None: + def _expand(self, node) -> None: if not self.owns_node(node) or node.children: return # TreeNode owns whether this native disclosure still admits expansion. @@ -218,23 +215,19 @@ def _toggle_node(self, node: TreeNode[ContextNode]) -> None: super()._toggle_node(node) @on(Tree.NodeExpanded, "#context-tree") - def node_expanded(self, event: Tree.NodeExpanded[ContextNode]) -> None: + def node_expanded(self, event) -> None: if self.owns_node(event.node): self.intent.expanded.add(event.node.data.key) self._expand(event.node) @on(Tree.NodeCollapsed, "#context-tree") - def node_collapsed(self, event: Tree.NodeCollapsed[ContextNode]) -> None: + def node_collapsed(self, event) -> None: if self.owns_node(event.node): self.intent.expanded.discard(event.node.data.key) - def selected(self, model: ContextNode): + 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): @@ -261,9 +254,7 @@ def __init__(self, *, intents: dict[SessionIdentity, ContextTreeIntent]) -> None @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( @@ -288,9 +279,7 @@ def compose(self): show_line_numbers=False, id="context-detail", ) - yield Input( - placeholder="Export to a new text file · path", id="context-export-path" - ) + 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) -> None: @@ -328,11 +317,7 @@ def _observed(self, _value=None) -> None: @on(Worker.StateChanged) def context_read_finished(self, event: Worker.StateChanged) -> None: - 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) -> None: @@ -343,9 +328,7 @@ 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() @@ -355,57 +338,42 @@ def action_refresh(self) -> None: def _reading(self, identity: SessionIdentity): return ( - self.is_attached - and not get_current_worker().is_cancelled - and self.thread_view.thread_identity == identity + self.is_attached and not get_current_worker().is_cancelled and self.thread_view.thread_identity == identity ) def _working(self, group: str): - 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: bool=False) -> None: + async def _read(self, *, force: bool = False) -> None: await self.thread_view.thread_identity.inspect_owner( lambda identity: self._read_owner(identity, force=force), self.query_one(".context-status", Static).update, ) - async def _read_owner(self, identity: ThreadIdentity, *, force: bool=False): + async def _read_owner(self, identity: ThreadIdentity, *, force: bool = False): status = self.query_one(".context-status", Static) access = self.app.coordination_access previous = self.state 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: InspectionState, current: HoldingInspection, force: bool) -> None: - 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): @@ -424,14 +392,10 @@ 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: InspectionState) -> None: if updated is not self.state: @@ -463,10 +427,7 @@ def action_correct(self, event: Button.Pressed) -> None: 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) @@ -550,15 +511,9 @@ async def _search(self, captured: HoldingInspection, query: str, selected: Conte 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: HoldingInspection, query: str, selected): return ( @@ -619,10 +574,8 @@ def node_highlighted(self, event: Tree.NodeHighlighted[ContextNode]) -> None: self.intent.selected = event.node.data self._show_detail(event.node.data) - def _selected(self, model: ContextNode): - return ( - self.query_one(ContextTree).selected(model) if self.is_attached else False - ) + def _selected(self, model): + 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: ContextNode) -> None: diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 94b50c080..15ab85a9e 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -37,9 +37,9 @@ from toad.transcript_preparation import ( PageRequest, PreparedTranscriptPage, + ReplaceablePageSource, SavedHistory, SourceObservation, - ReplaceablePageSource, TranscriptPageWork, Unchanged, ) @@ -60,12 +60,11 @@ from toad.comms_root import CoordinationAccess, Observation from toad.core.input_events import UserInputSubmitted - from toad.work_preparation import PreparationRuntime, WorkKey from toad.widgets.comms_chat import CommsChatView from toad.widgets.transcript_history import TranscriptPageView - from toad.widgets.wire_history import WireHistory - from toad.widgets.transcript_lines import Lines + from toad.widgets.wire_history import WireHistory + from toad.work_preparation import PreparationRuntime, WorkKey @dataclass(frozen=True) @@ -162,9 +161,11 @@ def advanced(self, interval: WirePage, *, older: bool) -> WirePage: must adopt the actual empty Core page rather than remain unobserved. """ source = interval.source if self.undrawn else self.source - return (replace(self, before=interval.before, has_older=interval.has_older, source=source) - if older else replace(self, after=interval.after, has_newer=interval.has_newer, - source=source)) + return ( + replace(self, before=interval.before, has_older=interval.has_older, source=source) + if older + else replace(self, after=interval.after, has_newer=interval.has_newer, source=source) + ) class PreparedWirePage(PreparedTranscriptPage): @@ -186,7 +187,7 @@ class WireFragment(TranscriptFragment): evidence: MessagePage | None checks_notifications: ClassVar[bool] = False - def _unhandled_message(message: Message, **fields): + def _unhandled_message(message, **fields): raise TypeError(f"No fragment declared for {type(message.role).__name__}") _roles = singledispatch(_unhandled_message) @@ -201,7 +202,7 @@ def of(cls, message: Message, *, reader: str, **fields) -> WireFragment: return cls._roles.dispatch(type(message.role))(message, reader=reader, **fields) @classmethod - def from_message(cls, message: Message, *, reader: str, **fields): + def from_message(cls, message, *, reader, **fields): return cls((IncomingTranscript(message=message, reader=reader),), **fields) @property @@ -236,8 +237,10 @@ def text_rows(self, lines: Lines) -> range: """Read visibility uses the same block snapshots that draw this viewport.""" prepared = lines.prepared_blocks trailing = len(self.trailing(self.shown)) - return range(sum(block.height for block in prepared[:len(self.leading)]), - lines.height - sum(block.height for block in prepared[-trailing:] if trailing)) + return range( + sum(block.height for block in prepared[: len(self.leading)]), + lines.height - sum(block.height for block in prepared[-trailing:] if trailing), + ) class MembershipFragment(WireFragment, roles=(Membership,)): @@ -252,9 +255,13 @@ class MessageFragment(BusMessageFragment, WireFragment, roles=(ConversationRole, """A sent message: its header, text and the state of its notifications.""" @classmethod - def from_message(cls, message: Message, *, reader: str, **fields): - return cls((IncomingTranscript(message=message, reader=reader),), - text_part=message.body, row=MessageRow(message, reader), **fields) + def from_message(cls, message, *, reader, **fields): + return cls( + (IncomingTranscript(message=message, reader=reader),), + text_part=message.body, + row=MessageRow(message, reader), + **fields, + ) @property def notification_references(self): @@ -266,8 +273,14 @@ def leading(self): source = message.history_source if source is None: return super().leading - return (Gap(), Summary.titled(f"History · {source.original_root} · @{message.sender} " - f"created {message.sender_created_at}"), self.row.header) + return ( + Gap(), + Summary.titled( + f"History · {source.original_root} · @{message.sender} " f"created {message.sender_created_at}" + ), + self.row.header, + ) + @dataclass(frozen=True, eq=False) class WireSource: @@ -296,14 +309,14 @@ def references(self) -> CurrentDisplayReferences: def observe(self, seen: ConversationObservation) -> SourceObservation: """The observed state owns how changed or unchanged history is drawn.""" revision = self.conversation.history_view.revision( - self.comms.views, self.conversation.retained_target(), seen, worktree=str(self.project)) + self.comms.views, self.conversation.retained_target(), seen, worktree=str(self.project) + ) if revision == seen: return Unchanged() high = AfterSequence(LiveCursor(self.comms.views.latest_sequence())) # Core's display view owns which stores can replace this reading. # Notification/read/activity writes do not require a page to learn that. - observation = (ReplacedWireObservation if revision.display_changed_since(seen) - else GrowingWireObservation) + observation = ReplacedWireObservation if revision.display_changed_since(seen) else GrowingWireObservation return observation(revision, high) def read(self, request: PageRequest[WireCut], through: AfterSequence) -> PreparedTranscriptPage: @@ -312,12 +325,15 @@ def read(self, request: PageRequest[WireCut], through: AfterSequence) -> Prepare shown = MessageShown(style=self.style) fragments = tuple( WireFragment.of(message, shown=shown, evidence=page.evidence_for(message), reader=self.me) - for message in page.messages) + for message in page.messages + ) wire = WirePage( page.messages, BeforeMessage(page.messages[0].view_cursor) if page.messages else None, AfterMessage(page.messages[-1].view_cursor) if page.has_newer and page.messages else through, - page.has_older and bool(page.messages), page.has_newer and bool(page.messages), page, + page.has_older and bool(page.messages), + page.has_newer and bool(page.messages), + page, ) return PreparedWirePage.measured(wire, fragments) @@ -330,8 +346,9 @@ class RestyledViewportRequest(ReplacementViewportRequest): loader: WireSource def source(self, owner): - return WirePageBuffer(self.loader, owner.page_source.through, owner.page_source.runtime, - revision=owner.page_source.revision) + return WirePageBuffer( + self.loader, owner.page_source.through, owner.page_source.runtime, revision=owner.page_source.revision + ) @dataclass(frozen=True) @@ -352,12 +369,11 @@ class WireObservation(SourceObservation): class ReplacedWireObservation(WireObservation): async def apply(self, history: WireHistory, source: ReplaceablePageSource) -> bool: - return await history.show_newest(ObservedViewportRequest( - LatestPosition.hold(history.window), self)) + return await history.show_newest(ObservedViewportRequest(LatestPosition.hold(history.window), self)) class GrowingWireObservation(WireObservation): - async def apply(self, history: WireHistory, source) -> bool: + async def apply(self, history, source) -> bool: if not history.undrawn: published = await history.extend(self.through) else: @@ -376,7 +392,7 @@ class WirePageWork(TranscriptPageWork[WireCut]): def retain_result(self) -> bool: return self.request.cursor.kept - async def execute(self, runtime: PreparationRuntime, key: WorkKey): + async def execute(self, runtime, key): import asyncio prepared = await runtime.run_thread(self.loader.read, self.request, self.through) @@ -405,24 +421,33 @@ async def route_is_current(self) -> bool: return not self.closed and await self.runtime.run_thread(root_is_current, self.loader.comms.root) - def status_thread(self, access: CoordinationAccess): + def status_thread(self, access): return self.loader.conversation.status_thread(self.loader.comms.root, access) - def command_context(self, view: CommsChatView): + def command_context(self, view): 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), self.loader.me, - self.loader.project, view.query_ancestor(SessionScope).id) + references = ( + view.app.coordination_access.observation.observed().sidebar.observation.require_snapshot().references + ) + return TargetContext( + view.app, + self.loader.comms, + self.loader.conversation.destination(references), + self.loader.me, + self.loader.project, + view.query_ancestor(SessionScope).id, + ) async def submit_input(self, view: CommsChatView, event: UserInputSubmitted) -> None: conversation = self.loader.conversation - current = lambda: (view.is_attached and self is view.message_history.page_source - and conversation == view.conversation) + current = lambda: ( + view.is_attached and self is view.message_history.page_source and conversation == view.conversation + ) await view.submit_comms_message( - self.loader.comms.root, conversation, event.body, current, - partial(view._show_sent, body=event.body)) + self.loader.comms.root, conversation, event.body, current, partial(view._show_sent, body=event.body) + ) def schedule_refresh(self, view: CommsChatView): history = view.message_history @@ -434,24 +459,27 @@ def painted_read(self, history: WireHistory) -> PaintedRead: async def change_style(self, history: WireHistory) -> None: if not history.can_draw_on_screen: return - await history.show_newest(RestyledViewportRequest( - LatestPosition.hold(history.window), self.loader.restyled(self.loader.style.next()))) + await history.show_newest( + RestyledViewportRequest(LatestPosition.hold(history.window), self.loader.restyled(self.loader.style.next())) + ) async def read_revision(self): return await self.runtime.run_thread(self.loader.comms.views.revision) - async def observe_changes(self, revision: ConversationObservation): + async def observe_changes(self, revision): return await self.runtime.run_thread(self.loader.observe, revision) - async def accept_page(self, history, edge: TranscriptPageView, prepared: PreparedTranscriptPage) -> bool: + async def accept_page(self, history, edge, prepared) -> bool: if edge.prepared.page.accepts(prepared.page.source, self.loader.conversation.history_view): return True prepared.configure(batch_size=len(prepared.fragments)) - await history.show_newest(LatestReplacementViewportRequest(LatestPosition.hold(history.window), self.through), - arrivals=history._source_state.arriving(prepared, history.drawn_tail_followed)) + await history.show_newest( + LatestReplacementViewportRequest(LatestPosition.hold(history.window), self.through), + arrivals=history._source_state.arriving(prepared, history.drawn_tail_followed), + ) return False - def seen_page(self, history: WireHistory, seen): + def seen_page(self, history, seen): return self.loader.conversation.seen_page(history, seen) async def mark_page(self, page: MessagePage) -> None: @@ -459,19 +487,17 @@ async def mark_page(self, page: MessagePage) -> None: comms = self.loader.comms await asyncio.to_thread( - run_selected_write, comms.root, comms.views.mark_page_read, page, - worktree=str(self.loader.project)) + run_selected_write, comms.root, comms.views.mark_page_read, page, worktree=str(self.loader.project) + ) async def read_only(self) -> bool: - return await self.runtime.run_thread( - self.loader.conversation.read_only, self.loader.comms) + return await self.runtime.run_thread(self.loader.conversation.read_only, self.loader.comms) async def update_mentions(self, view: CommsChatView) -> None: await self.loader.conversation.update_mentions(view, self.loader.comms) - async def status_text(self, *, read_only: bool): - return await self.loader.conversation.status_text(self.loader.comms, - read_only=read_only) + async def status_text(self, *, read_only): + return await self.loader.conversation.status_text(self.loader.comms, read_only=read_only) def accepts(self, loader: WireSource, through) -> bool: # Earlier wire cuts stay valid while the bus grows. From 5de3715eea7df1476cac3e5971f576e0e2ea33da Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:45:03 -0400 Subject: [PATCH 09/10] Encode image attachments through the declared FieldCodec fields --- src/toad/acp/prompt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/acp/prompt.py b/src/toad/acp/prompt.py index fc733ff55..35c4ee043 100644 --- a/src/toad/acp/prompt.py +++ b/src/toad/acp/prompt.py @@ -3,6 +3,7 @@ from acp import schema from acp.helpers import ContentBlock +from agent_comms.field_codec import FieldCodec from agent_comms.image_inputs import ImageInput, prompt_images from toad.clipboard_image import attachment_directory @@ -38,7 +39,7 @@ def build(project_path: Path, prompt: str) -> list[ContentBlock]: uri = resource.path.resolve().as_uri() if resource.mime_type.startswith("image/") and resource.data is not None: image = ImageInput(base64.b64encode(resource.data).decode("ascii"), resource.mime_type) - prompt_content.append({**image.to_rpc(), "uri": uri}) + prompt_content.append({**FieldCodec.encode_record(image), "uri": uri}) image_spans.append((start, end)) elif resource.text is not None: prompt_content.append( From aade2453ab79204b793ddf385a44383c332b4c34 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:47:00 -0400 Subject: [PATCH 10/10] Preserve callback result types and stop the unowned driver lifetime --- src/toad/frame_presentation.py | 6 +++--- src/toad/wire_history.py | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 309d4b3c3..29fe73fde 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -92,12 +92,12 @@ class FrameState(DeclaredFamily, affix="Frame"): def scene(self): return self - def displayed(self, frame: FramePresentation): + def displayed(self, frame): writing = WritingFrame(self.scene) frame.state = writing frame.screen.app._driver.call_after_flush(partial(frame.written, writing)) - def defer(self, frame: FramePresentation, owner: Widget, callback: Callable[[], None]): + def defer[Result](self, frame: FramePresentation, owner: Widget, callback: Callable[[], Result]): frame.callbacks[owner, callback] = None owner.call_after_refresh(frame.flush_owner, owner, callback) @@ -168,7 +168,7 @@ class ClosedFrame(FrameState): def displayed(self, frame: FramePresentation): pass - def defer(self, frame: FramePresentation, owner: Widget, callback: Callable[[], None]): + def defer[Result](self, frame: FramePresentation, owner: Widget, callback: Callable[[], Result]): pass def begin(self, frame: FramePresentation): diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 15ab85a9e..0ba1ca38b 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -58,13 +58,11 @@ if TYPE_CHECKING: from agent_comms.read_basis import NamedViewer - from toad.comms_root import CoordinationAccess, Observation + from toad.comms_root import Observation from toad.core.input_events import UserInputSubmitted from toad.widgets.comms_chat import CommsChatView - from toad.widgets.transcript_history import TranscriptPageView from toad.widgets.transcript_lines import Lines from toad.widgets.wire_history import WireHistory - from toad.work_preparation import PreparationRuntime, WorkKey @dataclass(frozen=True)