From 388946f7ada8d97bda2ca8da19ac4f637a7999ef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:54:31 -0400 Subject: [PATCH 1/8] Declare PTY resource, terminal geometry and completion input types --- src/toad/terminal_execution.py | 38 +++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/toad/terminal_execution.py b/src/toad/terminal_execution.py index 43bed1aa5..1aec78597 100644 --- a/src/toad/terminal_execution.py +++ b/src/toad/terminal_execution.py @@ -13,7 +13,7 @@ import signal import termios from io import FileIO -from typing import Mapping +from typing import TYPE_CHECKING, Mapping from weakref import ref from acp import schema as protocol @@ -27,6 +27,10 @@ from toad.shell_read import shell_read from toad.terminal_environment import TerminalEnvironment +if TYPE_CHECKING: + from toad.widgets.terminal_tool import TerminalTool + + @dataclass class Command: """A command and corresponding environment.""" @@ -152,15 +156,15 @@ class _TerminalCompletionDecoder(MroDispatch): """Typed original POSIX members enter the terminal family at acquisition.""" completion: TerminalCompletion - def __init__(self, original): + def __init__(self, original: ChildOutcome): self.dispatch_sync(original) @handles(ExitedOutcome) - def exited(self, original): + def exited(self, original: ExitedOutcome): self.completion = ExitedTerminalOutcome(original) @handles(SignaledOutcome) - def signaled(self, original): + def signaled(self, original: SignaledOutcome): self.completion = SignaledTerminalOutcome(original) @@ -188,8 +192,8 @@ class PtyProcess: reader: asyncio.StreamReader @classmethod - async def acquire(cls, command: tuple[str, ...], *, env, cwd, width, height, - cleanup: AsyncExitStack, buffer_size=128 * 1024): + async def acquire(cls, command: tuple[str, ...], *, env: Mapping[str, str], cwd, width: int, height: int, + cleanup: AsyncExitStack, buffer_size: int=128 * 1024): master, slave = pty.openpty() master_file = cleanup.enter_context(os.fdopen(master, "rb", 0)) slave_file = cleanup.enter_context(os.fdopen(slave, "wb", 0)) @@ -222,12 +226,12 @@ async def acquire_child(): slave_file.close() return cls(child, master_file, reader) - def resize(self, width, height): + def resize(self, width: int, height: int): with suppress(OSError, ValueError): self.resize_fd(self.master.fileno(), width, height) @staticmethod - def resize_fd(fd, width, height): + def resize_fd(fd: int, width: int, height: int): termios.tcsetwinsize(fd, (height, width)) async def write(self, data: bytes) -> int: @@ -255,10 +259,10 @@ def outcome(self) -> TerminalOutcome: ... def kill(self) -> None: pass - def resize(self, width, height): + def resize(self, width: int, height: int): pass - async def write(self, data): + async def write(self, data: bytes): return 0 async def close(self): @@ -305,7 +309,7 @@ class ActiveTerminalOperation(TerminalOperation): def start(self, execution): raise RuntimeError("Terminal execution already started") - def settle_startup(self, task): + def settle_startup(self, task: asyncio.Task[TerminalCompletion]): # A cancelled task may never enter run(), so its original cancellation # must settle the startup waiter too. Entered failures settle in run(). if task.cancelled() and not self.ready.done(): @@ -321,11 +325,11 @@ def outcome(self): except BaseException as error: return FailedTerminalOutcome(error) - def resize(self, width, height): + def resize(self, width: int, height: int): if self.ready.done() and not self.task.done(): self.ready.result().resize(width, height) - async def write(self, data): + async def write(self, data: bytes): if self.ready.done() and not self.task.done(): return await self.ready.result().write(data) return 0 @@ -387,7 +391,7 @@ def tool_state(self): output, truncated = self.get_output() return ToolState(output, truncated, self.operation.outcome()) - def attach(self, terminal): + def attach(self, terminal: TerminalTool): self._view = ref(terminal) terminal.set_state(self.state) terminal.set_write_to_stdin(self.write_stdin) @@ -401,7 +405,7 @@ def project(self, scrollback: set[int] | None, alternate: set[int] | None): if (terminal := self._view()) is not None and terminal.is_attached: terminal.present_execution(scrollback, alternate) - def update_size(self, width, height): + def update_size(self, width: int, height: int): self.state.update_size(width, height) self.operation.resize(width, height) @@ -419,7 +423,7 @@ async def start(self): self.state.show_cursor = True await asyncio.shield(operation.ready) - async def run(self, ready): + async def run(self, ready: asyncio.Future[PtyProcess]): try: async with AsyncExitStack() as cleanup: command = self.command @@ -447,7 +451,7 @@ async def run(self, ready): # The task's result becomes authoritative only after this returns. asyncio.get_running_loop().call_soon(self.project, None, None) - async def write_stdin(self, text: str | bytes, hide_echo=False): + async def write_stdin(self, text: str | bytes, hide_echo: bool=False): data = text.encode("utf-8", "ignore") if isinstance(text, str) else text return await self.operation.write(data) From 0379c9901de7fbbe8d3fd9231497f8e39baacb6c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:55:14 -0400 Subject: [PATCH 2/8] Type agent lifecycle and view inputs from the existing session owners --- src/toad/agent.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/toad/agent.py b/src/toad/agent.py index 43bf05a7b..564eef263 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -1,5 +1,6 @@ from abc import abstractmethod from pathlib import Path +from typing import TYPE_CHECKING from agent_comms.thread_presentation import ThreadPresentation from textual.content import Content @@ -12,6 +13,16 @@ from toad.surface_binding import SurfaceBinding +if TYPE_CHECKING: + from agent_comms.session_load import ExistingSessionLoadPlan + + from toad.conversation_turn import TurnOwner + from toad.screens.main import MainScreen + from toad.session_observation import SessionObservation + from toad.session_presentation import OperationalSessionSources + from toad.widgets.conversation import Conversation, ConversationSessionBinding + + class AgentBase(AgentConnection): """Base class for an 'agent'.""" @@ -35,7 +46,7 @@ def attach_view(self, view) -> None: def matches_agent(self, agent) -> bool: return self is agent - def cancel_allowed(self, turn): + def cancel_allowed(self, turn: TurnOwner): return turn.can_cancel def matches_session(self, session_id): @@ -44,14 +55,14 @@ def matches_session(self, session_id): def borrowed_processes(self): return (self.process,) - async def reconnect_session(self, source, plan) -> None: + async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None: await self.session.reconnect(plan) - async def change_project(self, view, path) -> None: + async def change_project(self, view: Conversation, path: Path) -> None: project = await self.update_project(str(path)) view.feedback.flash(Content(f"Project changed to {project}"), style="success") - def request_compaction(self, view, instructions) -> None: + def request_compaction(self, view: Conversation, instructions) -> None: view.compact_context(instructions) def begin_submission(self, owner, submission): @@ -69,49 +80,49 @@ def accepts_failed_input(self, message): def send_now(self, owner): return owner.send_bound_now(self) - def invalidate_observation(self, observation) -> None: + def invalidate_observation(self, observation: SessionObservation) -> None: observation.schedule(self) @property def automatic_titles(self) -> bool: return self.observed_thread() is None - async def project_changed(self, path) -> None: + async def project_changed(self, path: Path) -> None: from toad.db import DB self.project_root_path = path if (session_pk := self.session.pk) is not None: await DB().session_update_project(session_pk, path) - def refresh_context(self, view) -> None: + def refresh_context(self, view: Conversation) -> None: view.status = self.context_measurement.status() async def compact_context(self, instructions) -> None: await self.controller.compact_context(instructions) - async def cancel_turn(self, view) -> None: + async def cancel_turn(self, view: Conversation) -> None: await view.cancel_bound_turn(self) def accepts_message(self, message) -> bool: return message.publisher is self and message.event.session_id == self.session_id - def refresh_projection(self, view) -> None: + def refresh_projection(self, view: ConversationSessionBinding) -> None: view.status = self.context_measurement.status() view.turns.bound() view.submissions.publish_pending() view.delivery_observation.invalidate() - def start_session(self, view) -> None: + def start_session(self, view: ConversationSessionBinding) -> None: if self.ready: view.session_ready() - def start_agent(self, sources, view) -> None: + def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: """A connected agent is not created a second time.""" - def update_view(self, view) -> None: + def update_view(self, view: ConversationSessionBinding) -> None: view.apply_bound_agent(self) - def connected(self, view) -> None: + def connected(self, view: ConversationSessionBinding) -> None: view.agent_connected(self) @property @@ -125,7 +136,7 @@ def current_turn(self): def attach_surface(self, binding: SurfaceBinding) -> None: binding.close() - async def retire_surface(self, surface) -> None: + async def retire_surface(self, surface: Conversation) -> None: await self.stop() def observed_thread(self) -> tuple[str, str] | None: From 19a5b6d77117ec2235f1039c99d1079d7d39ce5e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:55:51 -0400 Subject: [PATCH 3/8] Declare transcript fragment and line acquisition input types --- src/toad/widgets/transcript_fragments.py | 33 +++++++++++++----------- src/toad/widgets/transcript_lines.py | 24 +++++++++-------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 7722aefd7..73698ee99 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -47,6 +47,9 @@ from toad.widgets.transcript_lines import AcquiredRows, NoRows, RowAcquisition if TYPE_CHECKING: + from toad.widgets.transcript_lines import ( + BlockLines, Lines, LineTheme, RichLineRenderer, TranscriptLinesRenderTask, + ) from toad.tool_output import ToolOutputPart from toad.widgets.committed_presentation import CommitClaim @@ -241,10 +244,10 @@ class LastPart(PartPosition): def notification_references(self, event): return (event.source,) - def status_blocks(self, shown): + def status_blocks(self, shown: MessageShown): return shown.status.blocks(shown) - def outgoing_sources(self, publication): + def outgoing_sources(self, publication: PublishedSentMessage): return (publication.reference,) @@ -252,13 +255,13 @@ class InnerPart(PartPosition): __slots__ = () has_details = False - def notification_references(self, event): + def notification_references(self, event: TranscriptEvent): return () - def status_blocks(self, shown): + def status_blocks(self, shown: MessageShown): return () - def outgoing_sources(self, publication): + def outgoing_sources(self, publication: PublishedSentMessage): return () @@ -299,10 +302,10 @@ def outgoing_sources(self): return () @classmethod - def capture_part(cls, event, text: str, **kwargs): + def capture_part(cls, event: TextTranscript, text: str, **kwargs): return cls((replace(event, text=text),), **kwargs) - def text_rows(self, lines) -> range: + def text_rows(self, lines: Lines) -> range: """The fragment's content occupies its drawn rows unless it owns a header.""" return range(lines.height) @@ -362,11 +365,11 @@ def link_place(self) -> int: return link_place() - def lines_for(self, width: int, theme): + def lines_for(self, width: int, theme: LineTheme): """Cached lookup is geometry-only; source changes use declared invalidation.""" return self._lines.for_view(width, theme) - def retains_lines(self, lines, theme) -> bool: + def retains_lines(self, lines: BlockLines, theme: LineTheme) -> bool: """Whether measured rows still belong to this source and palette. RowAcquisition.for_view owns wrapping those rows at the layout width. @@ -376,14 +379,14 @@ def retains_lines(self, lines, theme) -> bool: def invalidate_lines(self) -> None: self._lines = NoRows() - def hold_lines(self, request, lines) -> bool: + def hold_lines(self, request: TranscriptLinesRenderTask, lines: BlockLines) -> bool: """Report whether these rows were retained for the fragment's source.""" if request.blocks is self.line_blocks: self._lines = AcquiredRows(request.preparation, lines) return True return False - def plain_lines(self, width: int, renderer): + def plain_lines(self, width: int, renderer: RichLineRenderer): """Plain wrapped lines drawn until styled lines arrive; one per width.""" self._plain = acquired = self._plain.plain(width, renderer, self.line_blocks) return acquired.lines @@ -408,7 +411,7 @@ def has_details(self) -> bool: self.tool_call, any(part.has_details for part in self.output_parts) ) - def blocks(self, shown): + def blocks(self, shown: Shown): return AgentActivityPresentation.tool( self.tool_call, has_details=self.has_details, @@ -426,11 +429,11 @@ class BusMessageFragment(TranscriptFragment): row: MessageRow @classmethod - def capture_part(cls, event, text: str, **kwargs): + def capture_part(cls, event: TextTranscript, text: str, **kwargs): return cls((event,), text_part=text, **kwargs) @classmethod - def text_parts(cls, source, budget): + def text_parts(cls, source: PreparedMarkdownPart, budget: RenderBudget): # Even an empty bus message owns its header and notification footer. return super().text_parts(source, budget) or (source,) @@ -487,7 +490,7 @@ class ContextTranscriptFragment(TranscriptFragment): has_details = True - def blocks(self, shown): + def blocks(self, shown: Shown): summary = shown.summary("Agent coordination context") if not shown.details_open: return (summary,) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index 59a3f9f51..c56174240 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -17,6 +17,8 @@ from toad.patch import PatchCell if TYPE_CHECKING: + from toad.widgets.transcript_fragments import Shown, TranscriptFragment + from toad.widgets.transcript_history import LineHistory from toad.textual_colours import PaletteState, TextualColours from agent_comms.mro_dispatch import MroDispatch, handles @@ -232,7 +234,7 @@ def __iter__(self) -> Iterator[Row]: def height(self) -> int: return len(self.rows) - def prepared_rows(self, width, draw) -> PreparedLines: + def prepared_rows(self, width: int, draw: Callable[[LineBlock], PreparedLines]) -> PreparedLines: if width != self.width: raise ValueError("Prepared rows belong to a different width") return self @@ -281,7 +283,7 @@ class PreparedBlock(LineBlock): _rows: PreparedLines = PreparedLines(width=-1, rows=()) - def prepared_rows(self, width, draw) -> PreparedLines: + def prepared_rows(self, width: int, draw: Callable[[LineBlock], PreparedLines]) -> PreparedLines: rows = self._rows if rows.width != width: self._rows = rows = draw(self) @@ -578,10 +580,10 @@ class NoRows(RowAcquisition): def preparation(self) -> NoRows: return self - def for_view(self, width: int, theme): + def for_view(self, width: int, theme: LineTheme): return None - def retains(self, lines, theme) -> bool: + def retains(self, lines: BlockLines, theme: LineTheme) -> bool: return False @@ -594,16 +596,16 @@ class AcquiredRows(RowAcquisition): def preparation(self) -> LinePreparation: return self.lines.preparation - def for_view(self, width: int, theme): + def for_view(self, width: int, theme: LineTheme): if self.source.theme is not theme: return None self.lines = self.lines.rewrap(width) return self.lines - def retains(self, lines, theme) -> bool: + def retains(self, lines: BlockLines, theme: LineTheme) -> bool: return self.source.theme is theme and self.lines.preparation is lines.preparation - def plain(self, width: int, renderer, blocks): + def plain(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]): if self.source.theme is renderer.theme: self.lines = self.lines.rewrap(width) return self @@ -611,7 +613,7 @@ def plain(self, width: int, renderer, blocks): class PlainRows(AcquiredRows): - def retains(self, lines, theme) -> bool: + def retains(self, lines: BlockLines, theme: LineTheme) -> bool: return self.source.theme is theme and self.lines.preparation.blocks is lines.preparation.blocks @@ -633,11 +635,11 @@ def blocks(self) -> tuple[LineBlock, ...]: def current_for(self, view) -> bool: return self.context.current_for(view) and self.preparation.theme is view.app.colours.line_theme - def hold(self, view, fragment, lines) -> bool: + def hold(self, view, fragment: TranscriptFragment, lines: LinePreparation) -> bool: return self.current_for(view) and fragment.hold_lines(self, lines.wrap(view.line_width, view.app.colours.line_renderer)) @staticmethod - def show(view, fragment, shown, rendered) -> None: + def show(view: LineHistory, fragment: TranscriptFragment, shown: Shown, rendered) -> None: """Apply the requested state, retaining rows only through their render request.""" fragment.show(shown) if rendered is not None: @@ -702,7 +704,7 @@ def result_size(self, result: tuple[LinePreparation, ...]) -> int: items: tuple[TranscriptLinesRenderTask, ...] @classmethod - async def render_for(cls, view, preparations): + async def render_for(cls, view, preparations: Iterable[LinePreparation]): """One captured reference source for a batch; requests own result publication.""" preparations = tuple(preparations) if not preparations: From ad17bfc62d22096c0e411eaf317515f0affcec81 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:55:59 -0400 Subject: [PATCH 4/8] Declare SDK session update and conversation turn inputs --- src/toad/acp/session_updates.py | 72 ++++++++++++++++++++++----------- src/toad/conversation_turn.py | 47 ++++++++++++--------- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/src/toad/acp/session_updates.py b/src/toad/acp/session_updates.py index 9e05ea822..ef8c92c31 100644 --- a/src/toad/acp/session_updates.py +++ b/src/toad/acp/session_updates.py @@ -3,6 +3,10 @@ import asyncio from abc import abstractmethod +from typing import TYPE_CHECKING + +from pydantic import JsonValue +from agent_comms.acp_extension import AgentCommsUpdate from agent_comms.declared_family import DeclaredFamily @@ -12,97 +16,117 @@ from toad.acp.sdk_boundary import ValidateSessionUpdateTask from agent_comms.mro_dispatch import MroDispatch, handles from acp.schema import (UserMessageChunk, AgentMessageChunk, AgentThoughtChunk, - ToolCallStart, ToolCallProgress, AgentPlanUpdate, AvailableCommandsUpdate, + ToolCallStart, ToolCallProgress, AgentPlanUpdate, AgentPlanContentUpdate, AgentPlanRemovedUpdate, + SessionNotification, AvailableCommandsUpdate, CurrentModeUpdate, ConfigOptionUpdate, SessionInfoUpdate, UsageUpdate, TextContentBlock) from toad.plan import decode_plan from toad.project_link import AcpLogLink +if TYPE_CHECKING: + from toad.acp.agent import Agent + from toad.acp.comms_updates import CommsUpdateConsumer + class SessionUpdateEffect(MroDispatch): """Effect registrations consume official SDK declarations, never raw shapes.""" - def __init__(self, agent, consumer): + def __init__(self, agent: Agent, consumer: CommsUpdateConsumer): self.agent, self.consumer = agent, consumer @property def route(self): return self.consumer.route - def require_supported(self, update): + def require_supported( + self, + update: UserMessageChunk + | AgentMessageChunk + | AgentThoughtChunk + | ToolCallStart + | ToolCallProgress + | AgentPlanUpdate + | AgentPlanContentUpdate + | AgentPlanRemovedUpdate + | AvailableCommandsUpdate + | CurrentModeUpdate + | ConfigOptionUpdate + | SessionInfoUpdate + | UsageUpdate, + ): if not any(self.handlers_for(update)): raise ValueError(f'ACP update capability is not supported: {type(update).__name__}') @handles(UserMessageChunk) - def user(self, update): + def user(self, update: UserMessageChunk): UserContentEffect(self.agent, self.route).dispatch_sync(update.content) @handles(AgentMessageChunk) - def message(self, update): + def message(self, update: AgentMessageChunk): AgentContentEffect(self.agent, self.route).dispatch_sync(update.content) @handles(AgentThoughtChunk) - def thought(self, update): + def thought(self, update: AgentThoughtChunk): ThoughtContentEffect(self.agent, self.route).dispatch_sync(update.content) @handles(ToolCallStart) - def tool_start(self, update): + def tool_start(self, update: ToolCallStart): self.agent.tools.begin(update) @handles(ToolCallProgress) - def tool_progress(self, update): + def tool_progress(self, update: ToolCallProgress): self.agent.tools.update(update) @handles(AgentPlanUpdate) - def plan(self, update): + def plan(self, update: AgentPlanUpdate): self.agent.controller.publish_plan(decode_plan(update.entries)) @handles(AvailableCommandsUpdate) - def commands(self, update): + def commands(self, update: AvailableCommandsUpdate): self.agent.controller.publish_commands(update.available_commands) @handles(CurrentModeUpdate) - def mode(self, update): + def mode(self, update: CurrentModeUpdate): self.agent.configuration.receive_current_mode(update.current_mode_id) @handles(ConfigOptionUpdate) - def config(self, update): + def config(self, update: ConfigOptionUpdate): self.agent.configuration.receive(update.config_options) @handles(SessionInfoUpdate) - def info(self, update): + def info(self, update: SessionInfoUpdate): if 'title' in update.model_fields_set: self.agent.events.publish(events.SessionInfoUpdate(update.title)) @handles(UsageUpdate) - def usage(self, update): + def usage(self, update: UsageUpdate): self.agent.context_measurement = ContextMeasurement.live(update.used, update.size, update.cost) self.agent.update_status_line() class MessageContentEffect(MroDispatch): - def __init__(self, agent, route): + def __init__(self, agent: Agent, route): self.agent, self.route = agent, route @handles(TextContentBlock) - def text(self, content): + def text(self, content: TextContentBlock): if content.text: self.publish(content) @abstractmethod - def publish(self, content): ... + def publish(self, content: TextContentBlock): ... class UserContentEffect(MessageContentEffect): - def publish(self, content): + def publish(self, content: TextContentBlock): self.agent.events.publish(events.UserMessage(content.type, content.text)) class ThoughtContentEffect(MessageContentEffect): - def publish(self, content): + def publish(self, content: TextContentBlock): self.agent.events.publish(events.Thinking(content.type, content.text)) class AgentContentEffect(MessageContentEffect): - def publish(self, content): + def publish(self, content: TextContentBlock): text = content.text if text.startswith('[agent error]'): text += f'\n\n{AcpLogLink.markdown(self.agent.presentation.log_path)}' @@ -112,11 +136,11 @@ def publish(self, content): class SessionNotificationOwner(ClientRequestOwner): - def __init__(self, agent): + def __init__(self, agent: Agent): super().__init__(agent) self.lock = asyncio.Lock() - async def receive(self, notification, raw_update): + async def receive(self, notification: SessionNotification, raw_update: dict[str, JsonValue]): authority = ClientSessionRequest(self.agent, self.agent.session_id) async with self.lock: if authority.retired or not authority.binding.accepts_notification(notification.session_id): @@ -127,13 +151,13 @@ async def receive(self, notification, raw_update): return validation.publish(self, notification.session_id, raw_update, notification.field_meta) - def reject(self, session_id, update, metadata, error): + def reject(self, session_id: str, update, metadata, error: str): self.agent.log( f"[ACP rejected session/update] raw={{'sessionId': {session_id!r}, " f"'update': {update!r}, '_meta': {metadata!r}}}; validation={error}") self.agent.events.publish(events.RejectedSessionUpdate()) - def publish(self, session_id, update, facts): + def publish(self, session_id: str, update: SessionNotification, facts: tuple[AgentCommsUpdate, ...]): metadata = update.update.field_meta consumer = self.agent.comms_consumer_class(self.agent, session_id) effect = SessionUpdateEffect(self.agent, consumer) diff --git a/src/toad/conversation_turn.py b/src/toad/conversation_turn.py index 07ea47e1b..2afef99e2 100644 --- a/src/toad/conversation_turn.py +++ b/src/toad/conversation_turn.py @@ -1,5 +1,8 @@ """Local ACP turns and source-fenced projections of the backend's turn owner.""" +from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable +from typing import TYPE_CHECKING from dataclasses import dataclass, replace from agent_comms.activity import ActivityState @@ -7,6 +10,14 @@ from agent_comms.turn_lease import NoTurn as IdleTurn, TurnState from agent_comms.ui_model.status import ThreadStatusModel +if TYPE_CHECKING: + from textual.widget import Widget + + from toad.acp.agent import Agent + from toad.core_event_carrier import CoreEventMessage + from toad.response_delivery import ResponseDelivery + from toad.widgets.conversation import Conversation + class TurnOwner(DeclaredFamily, affix="Turn"): managed_id = None @@ -49,7 +60,7 @@ def accepts_update(self, update) -> bool: def matches_settlement(self, turn_id) -> bool: return not turn_id - def captured_snapshot(self, widgets): + def captured_snapshot(self, widgets: tuple[Widget, ...]): """Each original claim owns capture while this source is active.""" from toad.widgets.committed_presentation import CommitParticipant @@ -60,7 +71,7 @@ def captured_snapshot(self, widgets): def with_activity(self, activity: str) -> "TurnOwner": return ActivityTurn(activity) if activity else self - def response_stream(self, delivery): + def response_stream(self, delivery: ResponseDelivery): from toad.live_output import CompleteResponseStream return CompleteResponseStream(delivery) @@ -100,7 +111,7 @@ def matches_settlement(self, turn_id) -> bool: def with_activity(self, activity: str) -> TurnOwner: return replace(self, activity=activity or "Thinking…") - def response_stream(self, delivery): + def response_stream(self, delivery: ResponseDelivery): from toad.live_output import ResponseStream return ResponseStream(delivery, turn_id=self.managed_id) @@ -152,11 +163,11 @@ def accepts_update(self, update) -> bool: def matches_settlement(self, turn_id): return self.state.matches(turn_id) - def with_activity(self, activity): + def with_activity(self, activity: str): # Text, tool and heartbeat rendering cannot change backend phase. return self - def response_stream(self, delivery): + def response_stream(self, delivery: ResponseDelivery): if self.busy: from toad.live_output import ResponseStream return ResponseStream(delivery, turn_id=self.managed_id) @@ -175,7 +186,7 @@ def owner(self) -> TurnOwner: ... def status_row(self, observed: ThreadStatusModel) -> ThreadStatusModel: ... @abstractmethod - def accepts(self, message) -> bool: ... + def accepts(self, message: CoreEventMessage) -> bool: ... @abstractmethod def describe(self, activity: str) -> None: ... @@ -183,7 +194,7 @@ def describe(self, activity: str) -> None: ... @abstractmethod def reset(self) -> None: ... - async def present_input(self, view, text) -> None: + async def present_input(self, view: Conversation, text: str) -> None: """Display managed inputs only when the native process reports they started.""" def start_client(self) -> None: @@ -201,21 +212,21 @@ def __init__(self, agent=None): def owner(self): return self._owner - def status_row(self, observed): + def status_row(self, observed: ThreadStatusModel): owner = self.owner return ThreadStatusModel.known("", owner.activity_state.presentation("", owner.activity)) - def accepts(self, message): + def accepts(self, message: CoreEventMessage): """A local turn cannot accept a backend-owned managed publication.""" return False - def describe(self, activity): + def describe(self, activity: str): self._owner = self._owner.with_activity(activity) def reset(self): self._owner = NoTurn() - async def present_input(self, view, text): + async def present_input(self, view: Conversation, text: str): from toad.widgets.user_input import UserInput await view.post(UserInput(text)) @@ -229,7 +240,7 @@ def finish_client(self): class ManagedTurnBinding(TurnBinding): managed = True - def __init__(self, agent): + def __init__(self, agent: Agent): self.agent = agent self._owner = ManagedTurn() self.sequence = 0 @@ -238,7 +249,7 @@ def __init__(self, agent): def owner(self): return self._owner - def status_row(self, observed): + def status_row(self, observed: ThreadStatusModel): return observed def accepts(self, message): @@ -255,7 +266,7 @@ def receive(self, state: TurnState): self.sequence += 1 return True - def describe(self, activity): + def describe(self, activity: str): """Only the backend publication changes a managed phase.""" def reset(self): @@ -265,7 +276,7 @@ def reset(self): class ConversationTurn: """Read the actual source binding; the widget keeps no managed state or cursor.""" - def __init__(self, changed, source): + def __init__(self, changed: Callable[[TurnOwner], None], source: Callable[[], TurnBinding]): self._source = source self._changed = changed @@ -284,7 +295,7 @@ def bound(self): def managed_id(self): return self.owner.managed_id - def changed(self, message): + def changed(self, message: CoreEventMessage): if not self.binding.accepts(message): return False # The binding changed before publishing this event. Comparing another @@ -292,12 +303,12 @@ def changed(self, message): self._changed(self.owner) return True - def describe(self, activity): + def describe(self, activity: str): before = self.owner self.binding.describe(activity) self._notify(before) - def _notify(self, before): + def _notify(self, before: TurnOwner): # Streaming describes the turn on every chunk and publications repeat # unchanged leases; consumers re-sync prompt, footer bindings and # details, so they hear only an actual change of owner. From 791ba5c4f7305031725d0264ea873b75125d5c37 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:56:13 -0400 Subject: [PATCH 5/8] Declare markdown rule and block navigation parameter owners --- src/toad/block_navigation.py | 54 +++++++++++++++++++++--------------- src/toad/markdown_links.py | 33 ++++++++++++---------- 2 files changed, 50 insertions(+), 37 deletions(-) diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 26de6263d..c8abfa6d3 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -4,7 +4,7 @@ from abc import abstractmethod from functools import cached_property -from typing import ClassVar, cast +from typing import TYPE_CHECKING, ClassVar, cast from agent_comms.declared_family import DeclaredFamily from textual.geometry import Region from textual.widget import Widget @@ -15,6 +15,14 @@ from agent_comms.ui_model.message_category import MessageCategory, OtherCategory +if TYPE_CHECKING: + from agent_comms.ui_model.message_category import AgentActivityBoundary + from textual.screen import Screen + + from toad.widgets.history_anchor import HistoryWindow + from toad.widgets.transcript_history import LineHistory + + class CursorDirection(PageEnd, DeclaredFamily, affix="Cursor"): @property @abstractmethod @@ -50,7 +58,7 @@ def select(self, widget: Widget) -> bool: ... def active(self) -> BlockCursor | None: return self if self.selected is not None else None - def accepts(self, owner) -> bool: + def accepts(self, owner: Widget) -> bool: return owner is self.selected def clear(self) -> None: @@ -69,7 +77,7 @@ def get_block_menu(self): def allow_maximize(self) -> bool: return False - def maximize(self, screen) -> None: + def maximize(self, screen: Screen) -> None: pass @property @@ -83,10 +91,10 @@ def owns_source(self, source) -> bool: def visible_region(self) -> Region | None: return None - def show_moved_to(self, window) -> None: + def show_moved_to(self, window: HistoryWindow) -> None: """Scroll for a keyboard move, which can land on a block out of view.""" - def show_clicked(self, window) -> None: + def show_clicked(self, window: HistoryWindow) -> None: """Scroll for a click, which lands on a block that is already in view.""" def export_render(self): @@ -98,13 +106,13 @@ class AtomicBlockCursor(BlockCursor): def selected(self): return self.block - def enter(self, direction): + def enter(self, direction: CursorDirection): return True - def move(self, direction): + def move(self, direction: CursorDirection): return False - def select(self, widget): + def select(self, widget: Widget): return widget is self.block or self.block in widget.ancestors def get_clipboard_text(self): @@ -120,7 +128,7 @@ def get_block_menu(self): def allow_maximize(self): return self.block.allow_maximize - def maximize(self, screen): + def maximize(self, screen: Screen): screen.maximize(self.block, container=False) self.block.focus() @@ -133,10 +141,10 @@ def visible_region(self): geometry = self.block.screen._compositor.visible_widgets.get(self.block) return None if geometry is None else geometry[0] - def show_moved_to(self, window): + def show_moved_to(self, window: HistoryWindow): window.scroll_to_center(self.block, immediate=True) - def show_clicked(self, window): + def show_clicked(self, window: HistoryWindow): # The clicked block stays where it is. Only a block taller than the # viewport whose top is scrolled off moves, so that its top shows. block, viewport = self.block.region, window.content_region @@ -158,7 +166,7 @@ class FragmentBlockCursor(BlockCursor): message (its fragment) and asks the history where its rows are. """ - def __init__(self, block): + def __init__(self, block: LineHistory): super().__init__(block) self.fragment = None @@ -176,7 +184,7 @@ def current(self): def selected(self): return self.block if self.current is not None else None - def enter(self, direction): + def enter(self, direction: CursorDirection): # The cursor arrives at the reader's side of the screen, not at an # end of the loaded history the reader may be far from. fragments = self.block.visible_fragments() or self.block.drawn_fragments @@ -185,7 +193,7 @@ def enter(self, direction): self.fragment = fragments[direction.entry(len(fragments))] return True - def move(self, direction): + def move(self, direction: CursorDirection): fragments = self.block.drawn_fragments current = self.current if current is None or all( @@ -204,7 +212,7 @@ def move(self, direction): # scrolling to it loads the next page. return direction.visible_more(self.block) - def select(self, widget): + def select(self, widget: Widget): fragment = self.block.fragment_under_mouse(widget) if fragment is None: return False @@ -229,7 +237,7 @@ def region(self): visible_region = region - def show_moved_to(self, window): + def show_moved_to(self, window: HistoryWindow): if (fragment := self.current) is not None: self.block.show_fragment(fragment) @@ -252,7 +260,7 @@ def selected(self): cursor = self.active return None if cursor is None else cursor.selected - def _enter_from(self, index, direction): + def _enter_from(self, index: int, direction: CursorDirection): children = self.block.displayed_children while 0 <= index < len(children): self._child = children[index] @@ -264,12 +272,12 @@ def _enter_from(self, index, direction): self._child = None return False - def enter(self, direction): + def enter(self, direction: CursorDirection): return self._enter_from( direction.entry(len(self.block.displayed_children)), direction ) - def move(self, direction): + def move(self, direction: CursorDirection): child = self.current if child is None: return False @@ -278,7 +286,7 @@ def move(self, direction): index = self.block.displayed_children.index(child) + direction.step return self._enter_from(index, direction) - def select(self, widget): + def select(self, widget: Widget): for child in self.block.displayed_children: if child.block_cursor.select(widget): self._child = child @@ -303,7 +311,7 @@ class ConversationEntry(AnimationEffects[Mix], BlockContent): def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> None: self.set_display_constraint("message-category", OtherCategory in selected) - def starts_agent_activity(self, boundary) -> bool: + def starts_agent_activity(self, boundary: AgentActivityBoundary) -> bool: return OtherCategory.observe(boundary) def repaint(self) -> None: @@ -331,7 +339,7 @@ def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> No "message-category", self.message_category in selected ) - def starts_agent_activity(self, boundary) -> bool: + def starts_agent_activity(self, boundary: AgentActivityBoundary) -> bool: return self.message_category.observe(boundary) @@ -407,7 +415,7 @@ def select(self, widget: Widget) -> bool: return False -def require_blocks(widgets): +def require_blocks(widgets: tuple[Widget, ...]): """Decode the Textual mount boundary once before category consumers.""" for widget in widgets: if not isinstance(widget, ConversationEntry): diff --git a/src/toad/markdown_links.py b/src/toad/markdown_links.py index 2a0e5083b..36eb53572 100644 --- a/src/toad/markdown_links.py +++ b/src/toad/markdown_links.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from pathlib import Path from threading import local +from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily from agent_comms.display_references import ReferenceContext @@ -19,6 +20,10 @@ from toad.project_link import ProjectLink from toad.file_position import FilePosition +if TYPE_CHECKING: + from textual.widget import Widget + + _PATH_PATTERN = re.compile( r"(?(?:(?:~|\.{1,2})?/)?(?:[\w.@+-]+/)+[\w.@+-]+|[\w.@+-]+\.[A-Za-z0-9][\w+-]*)" @@ -42,12 +47,12 @@ async def acquire(cls, widget): return cls(context.root, references) @classmethod - def containing(cls, widget): + def containing(cls, widget: Widget): from toad.project_path_owner import ProjectPathOwner owner = ProjectPathOwner.containing(widget) return cls(owner.project_root.resolve(), owner.reference_context) - def current_for(self, widget): + def current_for(self, widget: Widget): if widget.is_attached: return self == self.containing(widget) return False @@ -55,7 +60,7 @@ def current_for(self, widget): def for_source(self, source): return LinkContext(self.root, self.references.for_source(source)) - def matches(self, text): + def matches(self, text: str): found = [] for match in _PATH_PATTERN.finditer(text): link = ProjectLink.from_path(self.root, match.group()) @@ -79,12 +84,12 @@ class _ProjectTokens: class ProjectTokenRule(DeclaredFamily, affix="TokenRule"): @classmethod @abstractmethod - def resolve(cls, renderer, token, context): ... + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): ... class InlineTokenRule(ProjectTokenRule): @classmethod - def resolve(cls, renderer, token, context): + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): if token.children: token.children = renderer.resolve(token.children, _ProjectTokens(context.links)) return [token] @@ -92,7 +97,7 @@ def resolve(cls, renderer, token, context): class LinkOpenTokenRule(ProjectTokenRule): @classmethod - def resolve(cls, renderer, token, context): + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): href = str(token.attrGet("href") or "") target = ProjectLink.from_href(lambda: context.links.root, href) token.attrSet("href", LinkHref.capture(target)) @@ -102,14 +107,14 @@ def resolve(cls, renderer, token, context): class LinkCloseTokenRule(ProjectTokenRule): @classmethod - def resolve(cls, renderer, token, context): + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): context.linked -= 1 return [token] class ImageTokenRule(ProjectTokenRule): @classmethod - def resolve(cls, renderer, token, context): + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): # Images are links to a local preview or an external address, never network fetches. href = str(token.attrGet("src") or "") content = Token("text", "", 0, content=token.content or Path(href).name or "Image") @@ -120,7 +125,7 @@ def resolve(cls, renderer, token, context): class TextTokenRule(ProjectTokenRule): @classmethod - def resolve(cls, renderer, token, context): + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): if context.linked: return [token] result, position = [], 0 @@ -146,7 +151,7 @@ class ProjectTokenRenderer: def __init__(self): self.rules = {FieldCodec.encode(rule): rule for rule in ProjectTokenRule.members_with(ProjectTokenRule)} - def resolve(self, tokens, context): + def resolve(self, tokens: list[Token], context: _ProjectTokens): result = [] for token in tokens: rule = self.rules.get(token.type) @@ -165,7 +170,7 @@ def __init__(self): _parser_state = _MarkdownParserState() -def parse_markdown_syntax(source, env=None, *, inline=False): +def parse_markdown_syntax(source: str, env=None, *, inline: bool=False): state = StateCore(source, _parser_state.parser, {} if env is None else env) state.inlineMode = inline for rule in _parser_state.syntax_rules: @@ -177,12 +182,12 @@ class MessageMarkdownParser: def __init__(self, context: LinkContext): self.context = context - def parse(self, source, env=None): + def parse(self, source: str, env=None): return self.resolve_tokens(parse_markdown_syntax(source, env)) - def parse_inline(self, source): + def parse_inline(self, source: str): return self.resolve_tokens(parse_markdown_syntax(source, inline=True)) - def resolve_tokens(self, tokens): + def resolve_tokens(self, tokens: list[Token]): # Linkify is syntax: resolve its URLs too, before any frontend sees them. return _parser_state.resolver.resolve(tokens, _ProjectTokens(self.context)) From 3279f0291e9f42b305f82ab61b0db85219e5dd11 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 12:57:39 -0400 Subject: [PATCH 6/8] Record unresolved family contracts without inventing parameter types --- src/toad/acp/session_updates.py | 75 +++++---- src/toad/agent.py | 11 +- src/toad/block_navigation.py | 32 ++-- src/toad/conversation_turn.py | 22 ++- src/toad/markdown_links.py | 12 +- src/toad/terminal_execution.py | 94 +++++++---- src/toad/widgets/transcript_fragments.py | 95 ++++++----- src/toad/widgets/transcript_lines.py | 206 ++++++++++++++++------- 8 files changed, 345 insertions(+), 202 deletions(-) diff --git a/src/toad/acp/session_updates.py b/src/toad/acp/session_updates.py index ef8c92c31..9e52619ed 100644 --- a/src/toad/acp/session_updates.py +++ b/src/toad/acp/session_updates.py @@ -1,24 +1,35 @@ """Process ACP notifications in order; update declarations own their effects.""" + from __future__ import annotations import asyncio from abc import abstractmethod from typing import TYPE_CHECKING -from pydantic import JsonValue +from acp.schema import ( + AgentMessageChunk, + AgentPlanContentUpdate, + AgentPlanRemovedUpdate, + AgentPlanUpdate, + AgentThoughtChunk, + AvailableCommandsUpdate, + ConfigOptionUpdate, + CurrentModeUpdate, + SessionInfoUpdate, + SessionNotification, + TextContentBlock, + ToolCallProgress, + ToolCallStart, + UsageUpdate, + UserMessageChunk, +) from agent_comms.acp_extension import AgentCommsUpdate +from agent_comms.mro_dispatch import MroDispatch, handles -from agent_comms.declared_family import DeclaredFamily - -from toad.core import events from toad.acp.client_session import ClientRequestOwner, ClientSessionRequest from toad.acp.context_measurement import ContextMeasurement from toad.acp.sdk_boundary import ValidateSessionUpdateTask -from agent_comms.mro_dispatch import MroDispatch, handles -from acp.schema import (UserMessageChunk, AgentMessageChunk, AgentThoughtChunk, - ToolCallStart, ToolCallProgress, AgentPlanUpdate, AgentPlanContentUpdate, AgentPlanRemovedUpdate, - SessionNotification, AvailableCommandsUpdate, - CurrentModeUpdate, ConfigOptionUpdate, SessionInfoUpdate, UsageUpdate, TextContentBlock) +from toad.core import events from toad.plan import decode_plan from toad.project_link import AcpLogLink @@ -29,6 +40,7 @@ class SessionUpdateEffect(MroDispatch): """Effect registrations consume official SDK declarations, never raw shapes.""" + def __init__(self, agent: Agent, consumer: CommsUpdateConsumer): self.agent, self.consumer = agent, consumer @@ -38,22 +50,24 @@ def route(self): def require_supported( self, - update: UserMessageChunk - | AgentMessageChunk - | AgentThoughtChunk - | ToolCallStart - | ToolCallProgress - | AgentPlanUpdate - | AgentPlanContentUpdate - | AgentPlanRemovedUpdate - | AvailableCommandsUpdate - | CurrentModeUpdate - | ConfigOptionUpdate - | SessionInfoUpdate - | UsageUpdate, + update: ( + UserMessageChunk + | AgentMessageChunk + | AgentThoughtChunk + | ToolCallStart + | ToolCallProgress + | AgentPlanUpdate + | AgentPlanContentUpdate + | AgentPlanRemovedUpdate + | AvailableCommandsUpdate + | CurrentModeUpdate + | ConfigOptionUpdate + | SessionInfoUpdate + | UsageUpdate + ), ): if not any(self.handlers_for(update)): - raise ValueError(f'ACP update capability is not supported: {type(update).__name__}') + raise ValueError(f"ACP update capability is not supported: {type(update).__name__}") @handles(UserMessageChunk) def user(self, update: UserMessageChunk): @@ -93,7 +107,7 @@ def config(self, update: ConfigOptionUpdate): @handles(SessionInfoUpdate) def info(self, update: SessionInfoUpdate): - if 'title' in update.model_fields_set: + if "title" in update.model_fields_set: self.agent.events.publish(events.SessionInfoUpdate(update.title)) @handles(UsageUpdate) @@ -128,9 +142,10 @@ def publish(self, content: TextContentBlock): class AgentContentEffect(MessageContentEffect): def publish(self, content: TextContentBlock): text = content.text - if text.startswith('[agent error]'): - text += f'\n\n{AcpLogLink.markdown(self.agent.presentation.log_path)}' + if text.startswith("[agent error]"): + text += f"\n\n{AcpLogLink.markdown(self.agent.presentation.log_path)}" from toad.response_delivery import ResponseDelivery + stream = self.agent.presentation.turns.owner.response_stream(ResponseDelivery.from_route(self.route)) self.agent.events.publish(events.Update(content.type, text, stream)) @@ -140,13 +155,14 @@ def __init__(self, agent: Agent): super().__init__(agent) self.lock = asyncio.Lock() - async def receive(self, notification: SessionNotification, raw_update: dict[str, JsonValue]): + async def receive(self, notification, raw_update): authority = ClientSessionRequest(self.agent, self.agent.session_id) async with self.lock: if authority.retired or not authority.binding.accepts_notification(notification.session_id): return validation = await self.agent.controller.task_runner.run( - ValidateSessionUpdateTask(notification, raw_update)) + ValidateSessionUpdateTask(notification, raw_update) + ) if authority.retired: return validation.publish(self, notification.session_id, raw_update, notification.field_meta) @@ -154,7 +170,8 @@ async def receive(self, notification: SessionNotification, raw_update: dict[str, def reject(self, session_id: str, update, metadata, error: str): self.agent.log( f"[ACP rejected session/update] raw={{'sessionId': {session_id!r}, " - f"'update': {update!r}, '_meta': {metadata!r}}}; validation={error}") + f"'update': {update!r}, '_meta': {metadata!r}}}; validation={error}" + ) self.agent.events.publish(events.RejectedSessionUpdate()) def publish(self, session_id: str, update: SessionNotification, facts: tuple[AgentCommsUpdate, ...]): diff --git a/src/toad/agent.py b/src/toad/agent.py index 564eef263..64ca7a9fe 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -12,7 +12,6 @@ from toad.core.events import CoreEventStream from toad.surface_binding import SurfaceBinding - if TYPE_CHECKING: from agent_comms.session_load import ExistingSessionLoadPlan @@ -71,11 +70,7 @@ def begin_submission(self, owner, submission): def accepts_failed_input(self, message): if message.publisher is not self or message.event.session_id != self.session_id: return False - return ( - self.queue_attachment.accepts_request(message.event.queue_scope) - if message.event.recover_draft - else True - ) + return self.queue_attachment.accepts_request(message.event.queue_scope) if message.event.recover_draft else True def send_now(self, owner): return owner.send_bound_now(self) @@ -143,9 +138,7 @@ def observed_thread(self) -> tuple[str, str] | None: """The (wire root, thread name) whose status this agent shows; none without coordination.""" return None - async def observe_thread_presentation( - self, presentation: ThreadPresentation - ) -> None: + async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: """Consume a published canonical change; unmanaged agents need no binding.""" @abstractmethod diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index c8abfa6d3..73128954b 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -5,15 +5,16 @@ from abc import abstractmethod from functools import cached_property from typing import TYPE_CHECKING, ClassVar, cast + from agent_comms.declared_family import DeclaredFamily +from agent_comms.ui_model.message_category import MessageCategory, OtherCategory from textual.geometry import Region from textual.widget import Widget -from toad.block_content import BlockContent -from toad.frame_presentation import AnimationEffects from ui_theme.effects import NEW_TEXT, Fade, Mix # type: ignore[import-untyped] -from toad.widgets.presentation_window import PageEnd, OlderEnd, NewerEnd -from agent_comms.ui_model.message_category import MessageCategory, OtherCategory +from toad.block_content import BlockContent +from toad.frame_presentation import AnimationEffects +from toad.widgets.presentation_window import NewerEnd, OlderEnd, PageEnd if TYPE_CHECKING: from agent_comms.ui_model.message_category import AgentActivityBoundary @@ -174,11 +175,7 @@ def __init__(self, block: LineHistory): def current(self): """The message the cursor rests on, while the history still draws it.""" fragment = self.fragment - return ( - fragment - if fragment is not None and self.block.draws_fragment(fragment) - else None - ) + return fragment if fragment is not None and self.block.draws_fragment(fragment) else None @property def selected(self): @@ -196,15 +193,10 @@ def enter(self, direction: CursorDirection): def move(self, direction: CursorDirection): fragments = self.block.drawn_fragments current = self.current - if current is None or all( - current is not seen for seen in self.block.visible_fragments() - ): + if current is None or all(current is not seen for seen in self.block.visible_fragments()): # The reader scrolled away from the cursor: it starts again from what they see. return self.enter(direction) - index = ( - next(index for index, drawn in enumerate(fragments) if drawn is current) - + direction.step - ) + index = next(index for index, drawn in enumerate(fragments) if drawn is current) + direction.step if 0 <= index < len(fragments): self.fragment = fragments[index] return True @@ -273,9 +265,7 @@ def _enter_from(self, index: int, direction: CursorDirection): return False def enter(self, direction: CursorDirection): - return self._enter_from( - direction.entry(len(self.block.displayed_children)), direction - ) + return self._enter_from(direction.entry(len(self.block.displayed_children)), direction) def move(self, direction: CursorDirection): child = self.current @@ -335,9 +325,7 @@ def message_category(self) -> type[MessageCategory]: raise NotImplementedError def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> None: - self.set_display_constraint( - "message-category", self.message_category in selected - ) + self.set_display_constraint("message-category", self.message_category in selected) def starts_agent_activity(self, boundary: AgentActivityBoundary) -> bool: return self.message_category.observe(boundary) diff --git a/src/toad/conversation_turn.py b/src/toad/conversation_turn.py index 2afef99e2..a1a88f9bf 100644 --- a/src/toad/conversation_turn.py +++ b/src/toad/conversation_turn.py @@ -1,13 +1,16 @@ """Local ACP turns and source-fenced projections of the backend's turn owner.""" + from __future__ import annotations + from abc import abstractmethod from collections.abc import Callable -from typing import TYPE_CHECKING from dataclasses import dataclass, replace +from typing import TYPE_CHECKING from agent_comms.activity import ActivityState from agent_comms.declared_family import DeclaredFamily -from agent_comms.turn_lease import NoTurn as IdleTurn, TurnState +from agent_comms.turn_lease import NoTurn as IdleTurn +from agent_comms.turn_lease import TurnState from agent_comms.ui_model.status import ThreadStatusModel if TYPE_CHECKING: @@ -64,15 +67,18 @@ def captured_snapshot(self, widgets: tuple[Widget, ...]): """Each original claim owns capture while this source is active.""" from toad.widgets.committed_presentation import CommitParticipant - return frozenset(widget for widget in widgets - if not isinstance(widget, CommitParticipant) - or widget.commit_claim.capture(not self.busy)) + return frozenset( + widget + for widget in widgets + if not isinstance(widget, CommitParticipant) or widget.commit_claim.capture(not self.busy) + ) def with_activity(self, activity: str) -> "TurnOwner": return ActivityTurn(activity) if activity else self def response_stream(self, delivery: ResponseDelivery): from toad.live_output import CompleteResponseStream + return CompleteResponseStream(delivery) @@ -99,6 +105,7 @@ def with_activity(self, activity: str) -> TurnOwner: @dataclass(frozen=True) class AgentTurn(TurnOwner): """A generic ACP peer owns its local request, but cannot control Comms turns.""" + managed_id: str | None = None activity: str = "Thinking…" started_at: float | None = None @@ -113,12 +120,14 @@ def with_activity(self, activity: str) -> TurnOwner: def response_stream(self, delivery: ResponseDelivery): from toad.live_output import ResponseStream + return ResponseStream(delivery, turn_id=self.managed_id) @dataclass(frozen=True) class ManagedTurn(TurnOwner): """The published lease owns phase, permissions, activity and identity.""" + state: TurnState = TurnState() @property @@ -170,6 +179,7 @@ def with_activity(self, activity: str): def response_stream(self, delivery: ResponseDelivery): if self.busy: from toad.live_output import ResponseStream + return ResponseStream(delivery, turn_id=self.managed_id) return super().response_stream(delivery) @@ -228,6 +238,7 @@ def reset(self): async def present_input(self, view: Conversation, text: str): from toad.widgets.user_input import UserInput + await view.post(UserInput(text)) def start_client(self): @@ -276,6 +287,7 @@ def reset(self): class ConversationTurn: """Read the actual source binding; the widget keeps no managed state or cursor.""" + def __init__(self, changed: Callable[[TurnOwner], None], source: Callable[[], TurnBinding]): self._source = source self._changed = changed diff --git a/src/toad/markdown_links.py b/src/toad/markdown_links.py index 36eb53572..91b237c63 100644 --- a/src/toad/markdown_links.py +++ b/src/toad/markdown_links.py @@ -1,10 +1,11 @@ """One syntax and link resolver for line, inline and Textual Markdown views.""" + from __future__ import annotations -from functools import partial import re from abc import abstractmethod from dataclasses import dataclass +from functools import partial from pathlib import Path from threading import local from typing import TYPE_CHECKING @@ -16,9 +17,9 @@ from markdown_it.rules_core import StateCore from markdown_it.token import Token +from toad.file_position import FilePosition from toad.line_blocks import LineLink, LinkHref, OpenReference from toad.project_link import ProjectLink -from toad.file_position import FilePosition if TYPE_CHECKING: from textual.widget import Widget @@ -49,6 +50,7 @@ async def acquire(cls, widget): @classmethod def containing(cls, widget: Widget): from toad.project_path_owner import ProjectPathOwner + owner = ProjectPathOwner.containing(widget) return cls(owner.project_root.resolve(), owner.reference_context) @@ -70,7 +72,7 @@ def matches(self, text: str): for match in self.references.find(text): if any(start < match.end and match.start < end for start, end, _ in found): continue - content = Token("text", "", 0, content=text[match.start:match.end]) + content = Token("text", "", 0, content=text[match.start : match.end]) found.append((match.start, match.end, OpenReference(match.reference).inline_tokens(content))) return sorted(found) @@ -152,7 +154,7 @@ def __init__(self): self.rules = {FieldCodec.encode(rule): rule for rule in ProjectTokenRule.members_with(ProjectTokenRule)} def resolve(self, tokens: list[Token], context: _ProjectTokens): - result = [] + result: list[Token] = [] for token in tokens: rule = self.rules.get(token.type) result.extend(rule.resolve(self, token, context) if rule else (token,)) @@ -170,7 +172,7 @@ def __init__(self): _parser_state = _MarkdownParserState() -def parse_markdown_syntax(source: str, env=None, *, inline: bool=False): +def parse_markdown_syntax(source: str, env=None, *, inline: bool = False): state = StateCore(source, _parser_state.parser, {} if env is None else env) state.inlineMode = inline for rule in _parser_state.syntax_rules: diff --git a/src/toad/terminal_execution.py b/src/toad/terminal_execution.py index 1aec78597..06e73e5a8 100644 --- a/src/toad/terminal_execution.py +++ b/src/toad/terminal_execution.py @@ -1,34 +1,40 @@ """Owns the original PTY and its outcomes; mounted views only display them.""" + from __future__ import annotations import asyncio -from abc import abstractmethod -from collections import deque -from contextlib import AsyncExitStack, suppress -from dataclasses import dataclass, field import codecs import os import pty import shlex import signal import termios +from abc import abstractmethod +from collections import deque +from contextlib import AsyncExitStack, suppress +from dataclasses import dataclass, field from io import FileIO from typing import TYPE_CHECKING, Mapping from weakref import ref from acp import schema as protocol from agent_comms.child_process import ( - AttachedChild, TerminalChildStdio, ChildOutcome, ExitedOutcome, SignaledOutcome, + AttachedChild, + ChildOutcome, + ExitedOutcome, + SignaledOutcome, + TerminalChildStdio, join_retirement, ) from agent_comms.declared_family import DeclaredFamily from agent_comms.mro_dispatch import MroDispatch, handles + from toad import ansi from toad.shell_read import shell_read from toad.terminal_environment import TerminalEnvironment if TYPE_CHECKING: - from toad.widgets.terminal_tool import TerminalTool + pass @dataclass @@ -45,15 +51,12 @@ class Command: """Original launch shell; catalog scripts explicitly select /bin/sh.""" @classmethod - def for_script(cls, script: str, *, env: Mapping[str, str] | None = None, - cwd: str | None = None) -> Command: + def for_script(cls, script: str, *, env: Mapping[str, str] | None = None, cwd: str | None = None) -> Command: """Catalog shell scripts use the standard shell and terminal environment.""" - return cls(script, TerminalEnvironment.for_child(os.environ) | (env or {}), - cwd, shell="/bin/sh") + return cls(script, TerminalEnvironment.for_child(os.environ) | (env or {}), cwd, shell="/bin/sh") @classmethod - def for_argv(cls, command: str, args: list[str], *, - env: Mapping[str, str], cwd: str) -> Command: + def for_argv(cls, command: str, args: list[str], *, env: Mapping[str, str], cwd: str) -> Command: """Compile ACP executable/arguments exactly once at its typed boundary.""" return cls(shlex.join([command, *args]), env, cwd) @@ -76,6 +79,7 @@ def return_code(self) -> int | None: def exit_status(self) -> protocol.TerminalExitStatus | None: return None # Official ACP absence, never an internal lifecycle field. + class RunningTerminalOutcome(TerminalOutcome): """The original execution task has not returned a completion witness.""" @@ -86,6 +90,7 @@ class UnstartedTerminalOutcome(TerminalOutcome): class RetiredTerminalOutcome(TerminalOutcome): """Acquisition was revoked before any command could start.""" + finished = True @@ -154,6 +159,7 @@ def wait_response(self): class _TerminalCompletionDecoder(MroDispatch): """Typed original POSIX members enter the terminal family at acquisition.""" + completion: TerminalCompletion def __init__(self, original: ChildOutcome): @@ -173,6 +179,7 @@ class FailedTerminalOutcome(TerminalOutcome): error: BaseException finished = True + @dataclass(frozen=True) class ToolState: output: str @@ -180,20 +187,31 @@ class ToolState: outcome: TerminalOutcome def response(self): - return protocol.TerminalOutputResponse(output=self.output, truncated=self.truncated, - exit_status=self.outcome.exit_status()) + return protocol.TerminalOutputResponse( + output=self.output, truncated=self.truncated, exit_status=self.outcome.exit_status() + ) @dataclass(frozen=True) class PtyProcess: """Complete acquired handles, scoped by the execution's AsyncExitStack.""" + child: AttachedChild master: FileIO reader: asyncio.StreamReader @classmethod - async def acquire(cls, command: tuple[str, ...], *, env: Mapping[str, str], cwd, width: int, height: int, - cleanup: AsyncExitStack, buffer_size: int=128 * 1024): + async def acquire( + cls, + command: tuple[str, ...], + *, + env: Mapping[str, str], + cwd, + width: int, + height: int, + cleanup: AsyncExitStack, + buffer_size: int = 128 * 1024, + ): master, slave = pty.openpty() master_file = cleanup.enter_context(os.fdopen(master, "rb", 0)) slave_file = cleanup.enter_context(os.fdopen(slave, "wb", 0)) @@ -204,13 +222,17 @@ async def acquire(cls, command: tuple[str, ...], *, env: Mapping[str, str], cwd, reader = asyncio.StreamReader(buffer_size) protocol = asyncio.StreamReaderProtocol(reader) transport, _ = await asyncio.get_running_loop().connect_read_pipe( - lambda: protocol, master_file, + lambda: protocol, + master_file, ) cleanup.callback(transport.close) async def acquire_child(): child = await AttachedChild.start( - command, stdio=TerminalChildStdio(slave_file), env=env, cwd=cwd, + command, + stdio=TerminalChildStdio(slave_file), + env=env, + cwd=cwd, ) cleanup.push_async_callback(child.stop) return child @@ -239,7 +261,7 @@ async def write(self, data: bytes) -> int: # thread that could write to an unrelated reused descriptor. try: return os.write(self.master.fileno(), data) - except (OSError, ValueError): + except OSError, ValueError: return 0 def kill(self) -> None: @@ -367,10 +389,16 @@ async def retire_task(self): class TerminalExecution: """One ANSI/output owner and one acquired operation, independent of views.""" - def __init__(self, command: Command, output_byte_limit: int | None = None, *, - width: int = ansi.TerminalState.DEFAULT_WIDTH, - height: int = ansi.TerminalState.DEFAULT_HEIGHT, - state: ansi.TerminalState | None = None): + + def __init__( + self, + command: Command, + output_byte_limit: int | None = None, + *, + width: int = ansi.TerminalState.DEFAULT_WIDTH, + height: int = ansi.TerminalState.DEFAULT_HEIGHT, + state: ansi.TerminalState | None = None, + ): self._command = command self._output_byte_limit = output_byte_limit self._output: deque[bytes] = deque() @@ -378,8 +406,7 @@ def __init__(self, command: Command, output_byte_limit: int | None = None, *, self._output_bytes_count = 0 self.operation: TerminalOperation = NewTerminalOperation() # An existing ANSI resource retains prefixes and sequential command output. - self.state = (ansi.TerminalState(self.write_stdin, width=width, height=height) - if state is None else state) + self.state = ansi.TerminalState(self.write_stdin, width=width, height=height) if state is None else state self._view = lambda: None @property @@ -391,7 +418,7 @@ def tool_state(self): output, truncated = self.get_output() return ToolState(output, truncated, self.operation.outcome()) - def attach(self, terminal: TerminalTool): + def attach(self, terminal): self._view = ref(terminal) terminal.set_state(self.state) terminal.set_write_to_stdin(self.write_stdin) @@ -428,8 +455,12 @@ async def run(self, ready: asyncio.Future[PtyProcess]): async with AsyncExitStack() as cleanup: command = self.command acquired = await PtyProcess.acquire( - command.shell_command, env=os.environ | command.env, cwd=command.cwd, - width=self.state.width, height=self.state.height, cleanup=cleanup, + command.shell_command, + env=os.environ | command.env, + cwd=command.cwd, + width=self.state.width, + height=self.state.height, + cleanup=cleanup, ) ready.set_result(acquired) decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") @@ -451,7 +482,7 @@ async def run(self, ready: asyncio.Future[PtyProcess]): # The task's result becomes authoritative only after this returns. asyncio.get_running_loop().call_soon(self.project, None, None) - async def write_stdin(self, text: str | bytes, hide_echo: bool=False): + async def write_stdin(self, text: str | bytes, hide_echo: bool = False): data = text.encode("utf-8", "ignore") if isinstance(text, str) else text return await self.operation.write(data) @@ -497,10 +528,7 @@ def is_continuation(byte_value: int) -> bool: return (byte_value & 0b11000000) == 0b10000000 truncated = self._bytes_read > len(output_bytes) - if ( - self._output_byte_limit is not None - and len(output_bytes) > self._output_byte_limit - ): + if self._output_byte_limit is not None and len(output_bytes) > self._output_byte_limit: truncated = True output_bytes = output_bytes[len(output_bytes) - self._output_byte_limit :] # Must start on a utf-8 boundary diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 73698ee99..5f1b7b804 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -47,11 +47,15 @@ from toad.widgets.transcript_lines import AcquiredRows, NoRows, RowAcquisition if TYPE_CHECKING: - from toad.widgets.transcript_lines import ( - BlockLines, Lines, LineTheme, RichLineRenderer, TranscriptLinesRenderTask, - ) from toad.tool_output import ToolOutputPart from toad.widgets.committed_presentation import CommitClaim + from toad.widgets.transcript_lines import ( + BlockLines, + Lines, + LineTheme, + RichLineRenderer, + TranscriptLinesRenderTask, + ) @dataclass(frozen=True) @@ -76,10 +80,9 @@ def _split_table(self, part: PreparedMarkdownPart) -> Iterator[PreparedMarkdownP table = part.tokens[0] assert table.map is not None ranges = [] - for index, row in enumerate(lines[2:table.map[1]], 2): + for index, row in enumerate(lines[2 : table.map[1]], 2): if index > first and ( - index - first >= row_limit - or len(header) + row_characters + len(row) > character_limit + index - first >= row_limit or len(header) + row_characters + len(row) > character_limit ): ranges.append((first, index)) first = index @@ -111,8 +114,13 @@ def split(self, source: PreparedMarkdownPart) -> Iterator[PreparedMarkdownPart]: } starts = sorted({0, *blocks, len(lines)}) parts = [ - (start, stop, blocks.get(start, (0, None))[0], - blocks.get(stop, (len(source.tokens), None))[0], blocks.get(start, (0, None))[1]) + ( + start, + stop, + blocks.get(start, (0, None))[0], + blocks.get(stop, (len(source.tokens), None))[0], + blocks.get(start, (0, None))[1], + ) for start, stop in zip(starts, starts[1:]) ] pending_first = 0 @@ -121,15 +129,13 @@ def split(self, source: PreparedMarkdownPart) -> Iterator[PreparedMarkdownPart]: pending_token_stop = 0 pending_characters = 0 pending_lines = 0 + def select(first: int, stop: int, token_first: int, token_stop: int): - return source.select_blocks(text[offsets[first]:offsets[stop]], first, - slice(token_first, token_stop)) + return source.select_blocks(text[offsets[first] : offsets[stop]], first, slice(token_first, token_stop)) for start, stop, token_first, token_stop, kind in parts: - part = text[offsets[start]:offsets[stop]] - if kind == "table_open" and ( - len(part) > self.characters or part.count("\n") > self.lines - ): + part = text[offsets[start] : offsets[stop]] + if kind == "table_open" and (len(part) > self.characters or part.count("\n") > self.lines): if pending_characters: yield select(pending_first, pending_stop, pending_token_first, pending_token_stop) pending_characters = 0 @@ -137,12 +143,8 @@ def select(first: int, stop: int, token_first: int, token_stop: int): yield from self._split_table(select(start, stop, token_first, token_stop)) continue part_lines = part.count("\n") + int(bool(part) and not part.endswith("\n")) - exceeds = ( - pending_characters - and ( - pending_characters + len(part) > self.characters - or pending_lines + part_lines > self.lines - ) + exceeds = pending_characters and ( + pending_characters + len(part) > self.characters or pending_lines + part_lines > self.lines ) if exceeds: yield select(pending_first, pending_stop, pending_token_first, pending_token_stop) @@ -274,6 +276,7 @@ class TranscriptFragment: shown: Shown = field(default_factory=Shown, kw_only=True) # Whether the fragment has details a reader can show and hide. has_details: ClassVar[bool] = False + @property def arrival_position(self) -> int | str: """The first event owns the fragment's stable source position.""" @@ -302,7 +305,7 @@ def outgoing_sources(self): return () @classmethod - def capture_part(cls, event: TextTranscript, text: str, **kwargs): + def capture_part(cls, event, text: str, **kwargs): return cls((replace(event, text=text),), **kwargs) def text_rows(self, lines: Lines) -> range: @@ -346,8 +349,7 @@ def timestamp(self) -> float | None: @property def clipboard_text(self) -> str | None: """What copying this fragment gives: its text as written.""" - return "\n\n".join(event.text for event in self.events - if isinstance(event, TextTranscript)) or None + return "\n\n".join(event.text for event in self.events if isinstance(event, TextTranscript)) or None @property def block_menu(self) -> tuple[MenuItem, ...]: @@ -485,17 +487,17 @@ def outgoing_sources(self): def shown_live_by(self, claim: CommitClaim) -> bool: return claim.repeats_sent(self) + class ContextTranscriptFragment(TranscriptFragment): """The coordination context an agent was given: one line, and its text when opened.""" has_details = True - def blocks(self, shown: Shown): + def blocks(self, shown): summary = shown.summary("Agent coordination context") if not shown.details_open: return (summary,) - return (summary, *(Body(event.text, role=MutedRole, bar=MutedRole) - for event in self.events)) + return (summary, *(Body(event.text, role=MutedRole, bar=MutedRole) for event in self.events)) class TranscriptFragmentConsumer(MroProjection[None]): @@ -523,8 +525,12 @@ def sent(self, event: SentTranscript) -> None: @handles(PublishedSentMessage) def published(self, message: PublishedSentMessage) -> None: original = message.message.core_message(AssignedTranscriptSource.open(message.scope)) - self.text(message.message, SentMessageFragment, publication=message, - row=MessageRow(original, message.scope.recipient.name)) + self.text( + message.message, + SentMessageFragment, + publication=message, + row=MessageRow(original, message.scope.recipient.name), + ) @handles(UserTranscript, MarkdownTranscript) def text(self, event: TextTranscript, kind: type[TranscriptFragment] = TranscriptFragment, **kwargs) -> None: @@ -532,9 +538,14 @@ def text(self, event: TextTranscript, kind: type[TranscriptFragment] = Transcrip source = PreparedMarkdownPart.capture(event.text) parts = kind.text_parts(source, self.budget) self.fragments.extend( - kind.capture_part(event, part.text, continuation=index > 0, - part_position=LastPart(index=index) if index == len(parts) - 1 else InnerPart(index=index), - starts_agent_activity=starts_activity and index == 0, **kwargs) + kind.capture_part( + event, + part.text, + continuation=index > 0, + part_position=LastPart(index=index) if index == len(parts) - 1 else InnerPart(index=index), + starts_agent_activity=starts_activity and index == 0, + **kwargs, + ) for index, part in enumerate(parts) ) @@ -542,7 +553,9 @@ def text(self, event: TextTranscript, kind: type[TranscriptFragment] = Transcrip def tool(self, event: ToolTranscript) -> None: if event.tool_call_id not in self.tools: self.tools[event.tool_call_id] = ( - len(self.fragments), [], self.boundary.observe(event.category), + len(self.fragments), + [], + self.boundary.observe(event.category), ) self.fragments.append(None) # Reserve the call's original position. _, events, _ = self.tools[event.tool_call_id] @@ -550,7 +563,8 @@ def tool(self, event: ToolTranscript) -> None: def transcript_fragments( - events: tuple[TranscriptEvent, ...], *, + events: tuple[TranscriptEvent, ...], + *, resolve_sent: Callable[[Iterable[SentTranscript]], dict[int, SentTranscript | PublishedSentMessage]], ) -> tuple[TranscriptFragment, ...]: from toad.tool_output import ToolOutput @@ -580,12 +594,17 @@ def transcript_fragments( elif index in tools: tool_events, starts_activity, call, parts = tools[index] parts = call.project_output( - parts, partial(ToolOutput.apply_sent, resolved=resolved), + parts, + partial(ToolOutput.apply_sent, resolved=resolved), + ) + fragments.append( + ToolTranscriptFragment( + tool_events, + starts_agent_activity=starts_activity, + tool_call=call, + output_parts=parts, + ) ) - fragments.append(ToolTranscriptFragment( - tool_events, starts_agent_activity=starts_activity, - tool_call=call, output_parts=parts, - )) else: fragments.append(source) return tuple(fragments) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index c56174240..c5d26db72 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -17,9 +17,8 @@ from toad.patch import PatchCell if TYPE_CHECKING: - from toad.widgets.transcript_fragments import Shown, TranscriptFragment - from toad.widgets.transcript_history import LineHistory from toad.textual_colours import PaletteState, TextualColours + from toad.widgets.transcript_fragments import Shown, TranscriptFragment from agent_comms.mro_dispatch import MroDispatch, handles from rich._wrap import divide_line @@ -75,29 +74,44 @@ def style(self, role: type[LineRole]) -> Style: def _style(role: type[LineRole], style: Style, palette: PaletteState) -> Style: if role.background_tint: style = palette.tint_style(style, role.background_tint) - return style + Style(italic=role.italic or None, bold=role.bold or None, - underline=role.underline or None) + return style + Style(italic=role.italic or None, bold=role.bold or None, underline=role.underline or None) def with_colour(self, colour: type[ThemeColour], style: Style, palette: PaletteState) -> LineTheme: """This theme with one colour drawn another way, as a fade draws it part of the way through.""" - return replace(self, styles={role: self._style(role, style, palette) - if role.colour is colour else held for role, held in self.styles.items()}) + return replace( + self, + styles={ + role: self._style(role, style, palette) if role.colour is colour else held + for role, held in self.styles.items() + }, + ) @classmethod def of(cls, colours: TextualColours) -> LineTheme: # Native ANSI text retains its distinct dim attribute rather than a palette guess. - muted = (Style(dim=True) if colours.colour(ForegroundColour).ansi is not None - else Style(color=colours.colour(MutedColour).rich_color)) - return cls({role: cls._style(role, muted if role.colour is MutedColour - else Style(color=colours.colour(role.colour).rich_color), - colours.schemes[colours.app.theme].palette) for role in LineRole.members_with(LineRole)}, - colours.depth.name, diff=colours.app.settings.diff, syntax=SyntaxPalette.of(colours.app)) + muted = ( + Style(dim=True) + if colours.colour(ForegroundColour).ansi is not None + else Style(color=colours.colour(MutedColour).rich_color) + ) + return cls( + { + role: cls._style( + role, + muted if role.colour is MutedColour else Style(color=colours.colour(role.colour).rich_color), + colours.schemes[colours.app.theme].palette, + ) + for role in LineRole.members_with(LineRole) + }, + colours.depth.name, + diff=colours.app.settings.diff, + syntax=SyntaxPalette.of(colours.app), + ) def link_style(link: LineLink | None) -> Style: """The style that makes text run a link's action when clicked.""" - return Style.null() if link is None else Style( - underline=link.underlined or None, meta={"@click": link.click}) + return Style.null() if link is None else Style(underline=link.underlined or None, meta={"@click": link.click}) def _fit(text: str, width: int) -> str: @@ -115,6 +129,7 @@ def _text_width(block: Body | RichTextBody, width: int) -> int: def _clickable(rows: list[Row]) -> list[Row]: """Give each Markdown link the click action that opens it; Rich marks a link's cells with its address only.""" + def linked(segment: Segment) -> Segment: style = segment.style if style is None or not style.link: @@ -177,10 +192,14 @@ class PatchLayout: def __init__(self, patch: Patch, width: int, settings: DiffSettings) -> None: self.patch, self.settings = patch, settings self.number_width = patch.number_width - longest = max((cell_len(line.expandtabs(4)) for line in (*patch.before.values(), *patch.after.values())), default=0) + longest = max( + (cell_len(line.expandtabs(4)) for line in (*patch.before.values(), *patch.after.values())), default=0 + ) self.split = settings.view.use_split(width, 2 * (longest + self.number_width + 4) + 3) self.pane_width = max(1, (width - 3) // 2 if self.split else width) - self.prefix_width = (self.number_width + 1 if self.split else 2 * self.number_width + 2) + (2 if settings.annotations else 0) + self.prefix_width = (self.number_width + 1 if self.split else 2 * self.number_width + 2) + ( + 2 if settings.annotations else 0 + ) self.pane_width = settings.wrap.fitted_width(self.pane_width, longest + self.prefix_width) self.code_width = max(1, self.pane_width - self.prefix_width) @@ -210,7 +229,8 @@ def strips_at(self, place: Hashable) -> tuple[Strip, ...] | JoinedStrips: if strips is None: width = self.width strips = self.__dict__["strips"] = tuple( - Strip(row).extend_cell_length(width) for row in _each_link_apart(self, place)) + Strip(row).extend_cell_length(width) for row in _each_link_apart(self, place) + ) return strips @@ -223,9 +243,11 @@ class PreparedLines(Lines, LineBlock): retained_bytes: int = field(init=False) def __post_init__(self) -> None: - object.__setattr__(self, "retained_bytes", - 64 + sum(64 + 64 * len(row) + 4 * sum(len(segment.text) for segment in row) - for row in self.rows)) + object.__setattr__( + self, + "retained_bytes", + 64 + sum(64 + 64 * len(row) + 4 * sum(len(segment.text) for segment in row) for row in self.rows), + ) def __iter__(self) -> Iterator[Row]: return iter(self.rows) @@ -234,7 +256,7 @@ def __iter__(self) -> Iterator[Row]: def height(self) -> int: return len(self.rows) - def prepared_rows(self, width: int, draw: Callable[[LineBlock], PreparedLines]) -> PreparedLines: + def prepared_rows(self, width: int, draw) -> PreparedLines: if width != self.width: raise ValueError("Prepared rows belong to a different width") return self @@ -283,7 +305,7 @@ class PreparedBlock(LineBlock): _rows: PreparedLines = PreparedLines(width=-1, rows=()) - def prepared_rows(self, width: int, draw: Callable[[LineBlock], PreparedLines]) -> PreparedLines: + def prepared_rows(self, width: int, draw) -> PreparedLines: rows = self._rows if rows.width != width: self._rows = rows = draw(self) @@ -299,8 +321,15 @@ class SpanColumnLayout(Columns, PreparedBlock): def __init__(self, block: SpanColumns, theme: LineTheme | None = None) -> None: self.indent = block.indent - cells = [Text.assemble(*((span.text, (theme.style(span.role) if theme is not None else Style.null()) - + link_style(span.link)) for span in cell)) for cell in block.cells] + cells = [ + Text.assemble( + *( + (span.text, (theme.style(span.role) if theme is not None else Style.null()) + link_style(span.link)) + for span in cell + ) + ) + for cell in block.cells + ] super().__init__(cells, padding=(0, 2)) def rows(self, console: Console) -> tuple[Row, ...]: @@ -333,8 +362,13 @@ def __init__(self, theme: LineTheme, width: int = 1) -> None: @cached_property def console(self) -> Console: - return Console(width=self.width, color_system=self.theme.color_system, - force_terminal=True, file=io.StringIO(), legacy_windows=False) + return Console( + width=self.width, + color_system=self.theme.color_system, + force_terminal=True, + file=io.StringIO(), + legacy_windows=False, + ) def at_width(self, width: int) -> RichLineRenderer: self.width = self.console.width = max(1, width) @@ -357,6 +391,7 @@ def patch(self, patch: Patch) -> None: number_width, split = layout.number_width, layout.split pane_width, prefix_width, code_width = layout.pane_width, layout.prefix_width, layout.code_width console = self.console + def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): if cell is None: return [Text(" " * pane_width)] @@ -374,16 +409,23 @@ def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): for index, chunk in enumerate(chunks): chunk.truncate(code_width, overflow="crop", pad=True) chunk.stylize(tint) - gutter = Text(prefix if index == 0 else " " * prefix_width, - style=style + tint if cell.role is not TextRole else self.theme.style(MutedRole)) + gutter = Text( + prefix if index == 0 else " " * prefix_width, + style=style + tint if cell.role is not TextRole else self.theme.style(MutedRole), + ) result.append(gutter + chunk) return result title = patch.after_path or patch.before_path or "Edit" if patch.before_path != patch.after_path: title = f"{patch.before_path} → {patch.after_path}" - self.output.append((Segment(_fit(f"{title} (+{patch.additions}, −{patch.deletions})", self.width), - self.theme.style(MutedRole)),)) + self.output.append( + ( + Segment( + _fit(f"{title} (+{patch.additions}, −{patch.deletions})", self.width), self.theme.style(MutedRole) + ), + ) + ) for group_index, group in enumerate(patch.row_groups(split)): if group_index: self.output.append((Segment("…", self.theme.style(MutedRole)),)) @@ -392,7 +434,9 @@ def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): left, right = paint(old, before, "", ""), paint(new, after, "", "") blank = Text(" " * pane_width) for a, b in zip_longest(left, right, fillvalue=blank): - self.output.append(tuple((a + Text(" │ ", style=self.theme.style(MutedRole)) + b).render(console))) + self.output.append( + tuple((a + Text(" │ ", style=self.theme.style(MutedRole)) + b).render(console)) + ) else: cell = old or new source = before if old is not None else after @@ -403,8 +447,12 @@ def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): def rich_text(self, block: RichTextBody) -> None: inner = _text_width(block, self.width) console = self.console - rows = [tuple(row) for row in console.render_lines( - block.content, console.options.update(width=inner), style=self.theme.style(block.role), pad=False)] + rows = [ + tuple(row) + for row in console.render_lines( + block.content, console.options.update(width=inner), style=self.theme.style(block.role), pad=False + ) + ] self._body_rows(block, rows) @handles(Divider) @@ -418,15 +466,17 @@ def divider(self, block: Divider) -> None: # A title longer than the rule ends in an ellipsis; what follows is not drawn. text = _fit(span.text, room) room -= cell_len(text) - title.append(Segment(text, (rule if span.link is None else self.theme.style(span.role)) - + bold + link_style(span.link))) + title.append( + Segment( + text, (rule if span.link is None else self.theme.style(span.role)) + bold + link_style(span.link) + ) + ) if room == 0: break title.append(Segment(" ", rule)) length = sum(segment.cell_length for segment in title) left = max(0, (self.width - length) // 2) - self.output.append((Segment("─" * left, rule), *title, - Segment("─" * max(0, self.width - left - length), rule))) + self.output.append((Segment("─" * left, rule), *title, Segment("─" * max(0, self.width - left - length), rule))) @handles(Summary) def summary(self, block: Summary) -> None: @@ -436,15 +486,16 @@ def summary(self, block: Summary) -> None: text = _fit(span.text, room) room -= cell_len(text) row.append(Segment(text, self.theme.style(span.role) + link_style(span.link))) - row.extend(Segment(span.text, self.theme.style(span.role) + link_style(span.link)) - for span in block.kept) + row.extend(Segment(span.text, self.theme.style(span.role) + link_style(span.link)) for span in block.kept) self.output.append(tuple(row)) @handles(Spans) def spans(self, block: Spans) -> None: - self.output.extend((Segment(line[start:start + self.width]),) - for line in _plain_lines(block.text) - for start in _plain_spans(line, self.width)) + self.output.extend( + (Segment(line[start : start + self.width]),) + for line in _plain_lines(block.text) + for start in _plain_spans(line, self.width) + ) @handles(PreparedLines) def prepared(self, block: PreparedLines) -> None: @@ -469,17 +520,31 @@ def prepared_columns(self, block: SpanColumnLayout) -> None: @handles(StyledBody) def styled_body(self, block: StyledBody) -> None: source = block.source - rows = _clickable([tuple(line) for line in self.console.render_lines( - Renderables(block.content), self.console.options.update(width=_text_width(source, self.width)), - style=self.theme.style(source.role), pad=False)]) + rows = _clickable( + [ + tuple(line) + for line in self.console.render_lines( + Renderables(block.content), + self.console.options.update(width=_text_width(source, self.width)), + style=self.theme.style(source.role), + pad=False, + ) + ] + ) self._body_rows(source, rows) @handles(Body) def body(self, block: Body) -> None: inner = _text_width(block, self.width) style = self.theme.style(block.role) - self._body_rows(block, [(Segment(line[start:start + inner], style),) - for line in _plain_lines(block.text) for start in _plain_spans(line, inner)]) + self._body_rows( + block, + [ + (Segment(line[start : start + inner], style),) + for line in _plain_lines(block.text) + for start in _plain_spans(line, inner) + ], + ) def _body_rows(self, block: Body | RichTextBody, rows: list[Row]) -> None: if block.bar is not None: @@ -497,8 +562,7 @@ class LinePreparation: retained_bytes: int = field(init=False) def __post_init__(self) -> None: - object.__setattr__(self, "retained_bytes", - 256 + sum(block.retained_bytes for block in self.blocks)) + object.__setattr__(self, "retained_bytes", 256 + sum(block.retained_bytes for block in self.blocks)) def wrap(self, width: int, renderer: RichLineRenderer) -> BlockLines: return BlockLines(width, self, renderer) @@ -555,6 +619,7 @@ class PlainLines(BlockLines): def __init__(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]) -> None: super().__init__(width, LinePreparation(blocks, renderer.theme), renderer) + class RowAcquisition(ABC): """The source witness and rows acquired by one fragment.""" @@ -636,10 +701,12 @@ def current_for(self, view) -> bool: return self.context.current_for(view) and self.preparation.theme is view.app.colours.line_theme def hold(self, view, fragment: TranscriptFragment, lines: LinePreparation) -> bool: - return self.current_for(view) and fragment.hold_lines(self, lines.wrap(view.line_width, view.app.colours.line_renderer)) + return self.current_for(view) and fragment.hold_lines( + self, lines.wrap(view.line_width, view.app.colours.line_renderer) + ) @staticmethod - def show(view: LineHistory, fragment: TranscriptFragment, shown: Shown, rendered) -> None: + def show(view, fragment: TranscriptFragment, shown: Shown, rendered) -> None: """Apply the requested state, retaining rows only through their render request.""" fragment.show(shown) if rendered is not None: @@ -649,7 +716,9 @@ def show(view: LineHistory, fragment: TranscriptFragment, shown: Shown, rendered fragment.hold_lines(request, lines.wrap(view.line_width, view.app.colours.line_renderer)) def execute(self) -> LinePreparation: - return LinePreparation(tuple(next(self.handlers_for(block))(block) for block in self.blocks), self.preparation.theme) + return LinePreparation( + tuple(next(self.handlers_for(block))(block) for block in self.blocks), self.preparation.theme + ) @handles(LineBlock) def unchanged(self, block: LineBlock) -> LineBlock: @@ -660,8 +729,9 @@ def body(self, block: Body) -> LineBlock: return block.prepare(self.markdown) def markdown(self, block: Body) -> StyledBody: - console = MarkdownStyleConsole(force_terminal=True, file=io.StringIO(), - color_system=self.preparation.theme.color_system) + console = MarkdownStyleConsole( + force_terminal=True, file=io.StringIO(), color_system=self.preparation.theme.color_system + ) return StyledBody(block, console.prepare(block.text, self.context)) @handles(WireBody) @@ -679,19 +749,33 @@ def columns(self, block: SpanColumns) -> SpanColumnLayout: @handles(Spans) def spans(self, block: Spans) -> StyledBody: - text = Text.assemble(*((span.text, self.preparation.theme.style(span.role) + link_style(span.link)) - for span in block.spans), tab_size=4) + text = Text.assemble( + *((span.text, self.preparation.theme.style(span.role) + link_style(span.link)) for span in block.spans), + tab_size=4, + ) return self.rich_text(RichTextBody(text, role=TextRole, bar=None)) @handles(Patch) def patch(self, block: Patch) -> StyledPatch: def highlight(lines: dict[int, str], path: str): code = "\n".join(lines.values()).expandtabs(4) - syntax = Syntax(code, Syntax.guess_lexer(path, code), - theme=self.preparation.theme.syntax.pygments_theme, background_color="default") + syntax = Syntax( + code, + Syntax.guess_lexer(path, code), + theme=self.preparation.theme.syntax.pygments_theme, + background_color="default", + ) return dict(zip(lines, syntax.highlight(code).split("\n", allow_blank=True))) - return StyledPatch(block.before_path, block.after_path, block.before, block.after, block.groups, - highlight(block.before, block.before_path), highlight(block.after, block.after_path)) + + return StyledPatch( + block.before_path, + block.after_path, + block.before, + block.after, + block.groups, + highlight(block.before, block.before_path), + highlight(block.after, block.after_path), + ) @dataclass(frozen=True) @@ -704,7 +788,7 @@ def result_size(self, result: tuple[LinePreparation, ...]) -> int: items: tuple[TranscriptLinesRenderTask, ...] @classmethod - async def render_for(cls, view, preparations: Iterable[LinePreparation]): + async def render_for(cls, view, preparations): """One captured reference source for a batch; requests own result publication.""" preparations = tuple(preparations) if not preparations: From ae601d27904f00ad4e30b349bb09cf63867a1ee1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 13:01:03 -0400 Subject: [PATCH 7/8] Format annotated agent and navigation signatures to repository defaults --- src/toad/agent.py | 18 ++++++++++++++---- src/toad/block_navigation.py | 23 ++++++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/toad/agent.py b/src/toad/agent.py index 64ca7a9fe..153e411f1 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -54,7 +54,9 @@ def matches_session(self, session_id): def borrowed_processes(self): return (self.process,) - async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None: + async def reconnect_session( + self, source: MainScreen, plan: ExistingSessionLoadPlan + ) -> None: await self.session.reconnect(plan) async def change_project(self, view: Conversation, path: Path) -> None: @@ -70,7 +72,11 @@ def begin_submission(self, owner, submission): def accepts_failed_input(self, message): if message.publisher is not self or message.event.session_id != self.session_id: return False - return self.queue_attachment.accepts_request(message.event.queue_scope) if message.event.recover_draft else True + return ( + self.queue_attachment.accepts_request(message.event.queue_scope) + if message.event.recover_draft + else True + ) def send_now(self, owner): return owner.send_bound_now(self) @@ -111,7 +117,9 @@ def start_session(self, view: ConversationSessionBinding) -> None: if self.ready: view.session_ready() - def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: + def start_agent( + self, sources: OperationalSessionSources, view: Conversation + ) -> None: """A connected agent is not created a second time.""" def update_view(self, view: ConversationSessionBinding) -> None: @@ -138,7 +146,9 @@ def observed_thread(self) -> tuple[str, str] | None: """The (wire root, thread name) whose status this agent shows; none without coordination.""" return None - async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: + async def observe_thread_presentation( + self, presentation: ThreadPresentation + ) -> None: """Consume a published canonical change; unmanaged agents need no binding.""" @abstractmethod diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 73128954b..ee3c3150b 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -175,7 +175,11 @@ def __init__(self, block: LineHistory): def current(self): """The message the cursor rests on, while the history still draws it.""" fragment = self.fragment - return fragment if fragment is not None and self.block.draws_fragment(fragment) else None + return ( + fragment + if fragment is not None and self.block.draws_fragment(fragment) + else None + ) @property def selected(self): @@ -193,10 +197,15 @@ def enter(self, direction: CursorDirection): def move(self, direction: CursorDirection): fragments = self.block.drawn_fragments current = self.current - if current is None or all(current is not seen for seen in self.block.visible_fragments()): + if current is None or all( + current is not seen for seen in self.block.visible_fragments() + ): # The reader scrolled away from the cursor: it starts again from what they see. return self.enter(direction) - index = next(index for index, drawn in enumerate(fragments) if drawn is current) + direction.step + index = ( + next(index for index, drawn in enumerate(fragments) if drawn is current) + + direction.step + ) if 0 <= index < len(fragments): self.fragment = fragments[index] return True @@ -265,7 +274,9 @@ def _enter_from(self, index: int, direction: CursorDirection): return False def enter(self, direction: CursorDirection): - return self._enter_from(direction.entry(len(self.block.displayed_children)), direction) + return self._enter_from( + direction.entry(len(self.block.displayed_children)), direction + ) def move(self, direction: CursorDirection): child = self.current @@ -325,7 +336,9 @@ def message_category(self) -> type[MessageCategory]: raise NotImplementedError def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> None: - self.set_display_constraint("message-category", self.message_category in selected) + self.set_display_constraint( + "message-category", self.message_category in selected + ) def starts_agent_activity(self, boundary: AgentActivityBoundary) -> bool: return self.message_category.observe(boundary) From 427a70ec4f305e2b9f31f11d5ada4dc09a96a84b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 13:07:37 -0400 Subject: [PATCH 8/8] Use SessionNotification as the owner of SDK update types --- src/toad/acp/session_updates.py | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/toad/acp/session_updates.py b/src/toad/acp/session_updates.py index 9e52619ed..014eb11f2 100644 --- a/src/toad/acp/session_updates.py +++ b/src/toad/acp/session_updates.py @@ -8,8 +8,6 @@ from acp.schema import ( AgentMessageChunk, - AgentPlanContentUpdate, - AgentPlanRemovedUpdate, AgentPlanUpdate, AgentThoughtChunk, AvailableCommandsUpdate, @@ -48,26 +46,9 @@ def __init__(self, agent: Agent, consumer: CommsUpdateConsumer): def route(self): return self.consumer.route - def require_supported( - self, - update: ( - UserMessageChunk - | AgentMessageChunk - | AgentThoughtChunk - | ToolCallStart - | ToolCallProgress - | AgentPlanUpdate - | AgentPlanContentUpdate - | AgentPlanRemovedUpdate - | AvailableCommandsUpdate - | CurrentModeUpdate - | ConfigOptionUpdate - | SessionInfoUpdate - | UsageUpdate - ), - ): - if not any(self.handlers_for(update)): - raise ValueError(f"ACP update capability is not supported: {type(update).__name__}") + def require_supported(self, notification: SessionNotification): + if not any(self.handlers_for(notification.update)): + raise ValueError(f"ACP update capability is not supported: {type(notification.update).__name__}") @handles(UserMessageChunk) def user(self, update: UserMessageChunk): @@ -179,7 +160,7 @@ def publish(self, session_id: str, update: SessionNotification, facts: tuple[Age consumer = self.agent.comms_consumer_class(self.agent, session_id) effect = SessionUpdateEffect(self.agent, consumer) try: - effect.require_supported(update.update) + effect.require_supported(update) except (TypeError, ValueError) as error: self.reject(session_id, update, metadata, str(error)) return