From 2809746a026445fad7cb1770efecfe6489577332 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 06:48:54 -0400 Subject: [PATCH 1/4] Annotate context and transcript parameters from checked inference Run autotyping followed by Pyrefly, then correct inferred types against existing callers and capability parents. Reject object, narrow empty defaults, sibling unions and cyclic imports. Use DispatchRequest.require_id for the three stale ContextManifest calls exposed by checking. --- src/toad/core/context_inspection.py | 127 +++++++++++++----------- src/toad/transcript_preparation.py | 25 +++-- src/toad/transcript_publication.py | 75 +++++++------- src/toad/widgets/context_explorer.py | 118 +++++++++++----------- src/toad/widgets/presentation_window.py | 47 ++++----- src/toad/widgets/sidebar_tree.py | 71 +++++++------ 6 files changed, 241 insertions(+), 222 deletions(-) diff --git a/src/toad/core/context_inspection.py b/src/toad/core/context_inspection.py index 3a8730e7d..eb61bf769 100644 --- a/src/toad/core/context_inspection.py +++ b/src/toad/core/context_inspection.py @@ -2,32 +2,39 @@ from __future__ import annotations import json -from pathlib import Path from abc import ABC, abstractmethod from dataclasses import dataclass, field, replace from functools import partial +from pathlib import Path from typing import TYPE_CHECKING, Awaitable, Callable -from agent_comms.field_codec import FieldCodec -from agent_comms.declared_family import DeclaredFamily from agent_comms.coordinator import Coordination -from agent_comms.working_memory_annotations import WorkingMemoryAnnotations +from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import FieldCodec from agent_comms.importing import ImportedSessionMetadata from agent_comms.mro_dispatch import handles -from toad.core.projection import MroProjection -from toad.thread_identity import ThreadIdentity from agent_comms.native_turn_context import NativeContextData from agent_comms.pi_payloads import PiMessage -from agent_comms.session_revision import SessionRevision, SessionObservation from agent_comms.registry_document import RegistryEntry +from agent_comms.session_revision import SessionObservation, SessionRevision from agent_comms.thread_status import ActiveThreadPresence, ThreadStatus from agent_comms.turn_context import ( - CodexRolloutProvenance, ContextManifest, ContextSegment, ContextSourceText, NativeMessages, Provenance, - SegmentManifest, TurnContext, + CodexRolloutProvenance, + ContextManifest, + ContextSegment, + ContextSourceText, + NativeMessages, + Provenance, + SegmentManifest, + TurnContext, ) +from agent_comms.working_memory_annotations import WorkingMemoryAnnotations from agent_comms.working_memory_labels import JevClassifier, ModelLabel from agent_comms.working_memory_questions import SpanAnswer +from toad.core.projection import MroProjection +from toad.thread_identity import ThreadIdentity + if TYPE_CHECKING: from agent_comms.comms import Comms from agent_comms.context_store import ContextManifestPage @@ -63,13 +70,13 @@ def public_text(self) -> str: """Only original public content is searchable, never private reasoning.""" return "" - def _find_public(self, query): + def _find_public(self, query: str): if query in self.public_text().casefold(): yield self for child in self.children(): yield from child._find_public(query) - async def find(self, query: str, *, limit=100): + async def find(self, query: str, *, limit: int=100): """Search one observed tree in one worker, without a second text index.""" from itertools import islice @@ -83,7 +90,7 @@ def read(): def search_roots(current_roots): return current_roots() - def search_description(self): + def search_description(self) -> str: return "Current public context" def correction_answers(self): @@ -95,13 +102,13 @@ async def correct(self, answer: type[SpanAnswer], worktree: Path): async def read(self): return await Coordination.run_worker(self.detail) - async def export(self, destination: Path): + async def export(self, destination: Path) -> None: """Export this original observation; never overwrite a session/source file.""" detail = await self.read() await Coordination.run_worker(partial(self._write_export, destination, detail)) @staticmethod - def _write_export(destination, detail): + def _write_export(destination, detail) -> None: with destination.open("x", encoding="utf-8") as output: output.write(detail) @@ -112,7 +119,7 @@ class ReferenceNode(ContextNode): read_source: Callable[[Provenance], Awaitable[ContextSourceText]] @property - def label(self): + def label(self) -> str: return f"Source · {self.source.public_description()}" def detail(self): @@ -121,7 +128,7 @@ def detail(self): def public_text(self): return self.source.public_description() - async def read(self): + async def read(self) -> str: original = await self.read_source(self.source) return f"{original.description}\n\n{original.text}" @@ -131,7 +138,7 @@ def search_roots(self, current_roots): def search_description(self): return self.label - async def find(self, query, *, limit=100): + async def find(self, query, *, limit: int=100): original = await self.read_source(self.source) if query in original.text.casefold() or query in original.description.casefold(): yield self @@ -154,7 +161,7 @@ def children(self): for i, source in enumerate(self.segment.provenance)), ) - def detail(self): + def detail(self) -> str: return (f"{self.segment.kind.public_title()}\nEstimate: {self.segment.tokens:,} tokens\n" f"Original bytes: {self.segment.utf8_bytes:,}\n" f"Digest: {self.segment.sha256}\n\n" @@ -168,7 +175,7 @@ class RecordedTurnNode(ContextNode): inspection: ContextInspection @property - def label(self): + def label(self) -> str: turn = self.manifest.turn.require_recorded() return f"Request {turn.occurrence.generation} · {turn.identity.value[:12]}" @@ -183,13 +190,13 @@ def search_roots(self, current_roots): def search_description(self): return self.label - def detail(self): + def detail(self) -> str: return (f"Recorded turn · {self.manifest.turn.require_recorded().identity.value}\n" f"Counter: {self.manifest.counter}\n\n" "Historical request observation, not an additional active context section. " "Segment counts are estimates, not provider measurements.") - async def find(self, query, *, limit=100): + async def find(self, query, *, limit: int=100): count = 0 for child in self.children(): async for match in child.find(query, limit=limit - count): @@ -229,18 +236,18 @@ async def source_text(self): return await self.inspection.recorded_segment( self.manifest, self.position, self.contributors) - async def read(self): + async def read(self) -> str: original = await self.source_text() return f"{original.description}\n\n{original.text}" - async def find(self, query, *, limit=100): + async def find(self, query, *, limit: int=100): # One complete selected value includes its annotation children. Search # that value once; opening a child still uses that exact child's path. original = await self.source_text() if query in original.text.casefold() or query in original.description.casefold(): yield self - def search_description(self): + def search_description(self) -> str: return f"{super().search_description()} · {self.manifest.turn.require_recorded().identity.value[:12]}" def original_segments(self): @@ -258,7 +265,7 @@ class AnnotationNode(ContextNode): source: RecordedSegmentNode @property - def label(self): + def label(self) -> str: original = self.annotation return (f"{original.question.question.public_title()} · {original.answer.public_title()} · " f"{original.public_description()}") @@ -273,7 +280,7 @@ async def read(self): original = await self.source.source_text() return self.describe_original(original) - def describe_original(self, original: ContextSourceText): + def describe_original(self, original: ContextSourceText) -> str: text = self.annotation.span.coordinates.public_text(original.text) provenance = "\n".join(source.public_description() for source in self.annotation.span.coordinates.provenance) @@ -289,7 +296,7 @@ def search_roots(self, current_roots): def search_description(self): return self.label - async def find(self, query, *, limit=100): + async def find(self, query, *, limit: int=100): if query in (await self.read()).casefold(): yield self @@ -336,7 +343,7 @@ def search_roots(self, current_roots): def search_description(self): return self.label - async def find(self, query, *, limit=100): + async def find(self, query, *, limit: int=100): # One search borrows each authenticated selected value once. These # buffers leave with the operation; no corpus or text index is kept. acquired = {} @@ -369,7 +376,7 @@ def children(self): for i, source in enumerate(self.segment.provenance)), ) - def detail(self): + def detail(self) -> str: sources = "\n\n".join( source.public_description() for source in self.segment.provenance @@ -386,7 +393,7 @@ class NativeMessageNode(NativeSegmentNode): position: int @property - def label(self): + def label(self) -> str: text = " ".join(self.message.text[:120].split()) return (f"{self.segment.declared_name.replace('_', ' ')} · " f"{self.message.declared_name} {self.position + 1} · {text or 'no public text'}") @@ -406,7 +413,7 @@ def detail(self): class SegmentNodes(MroProjection): - def __init__(self, key, read_source): + def __init__(self, key: str, read_source) -> None: self.key, self.read_source = key, read_source @handles(ContextSegment) @@ -422,12 +429,12 @@ def messages(self, segment): class NativeMessagesNode(NativeSegmentNode): page_size: int = 64 - def public_text(self): + def public_text(self) -> str: # This navigation container delegates searching to its original # message children rather than concatenating the same history twice. return "" - def detail(self): + def detail(self) -> str: return f"{self.label}\nExpand a message range to read each original public message." def children(self): @@ -445,7 +452,7 @@ class NativeMessageRange(ContextNode): stop: int @property - def label(self): + def label(self) -> str: return f"Messages {self.start + 1}–{self.stop}" def children(self): @@ -454,22 +461,22 @@ def children(self): message=PiMessage.from_wire(self.segment.messages[i]), position=i) for i in range(self.start, self.stop)) - def detail(self): + def detail(self) -> str: return "Current native message range; expand to read the original public text." class CoreInspection(ABC): """Availability of the backend's original Core contributor observation.""" - def roots(self, inspection): + def roots(self, inspection: ContextInspection): return () - def groups(self, inspection): + def groups(self, inspection: ContextInspection) -> tuple[tuple[str, tuple[ContextNode, ...], bool], ...]: return () @property @abstractmethod - def status(self): + def status(self) -> str: """Describe only this independently acquired contributor resource.""" @@ -478,7 +485,7 @@ class UnavailableCoreInspection(CoreInspection): error: str = "Core contributors have not been acquired" @property - def status(self): + def status(self) -> str: return f"Core instructions unavailable: {self.error}" @@ -493,7 +500,7 @@ def groups(self, inspection): return (("Current Core instructions · before next input", self.roots(inspection), True),) @property - def status(self): + def status(self) -> str: return "Current Core instructions available" @@ -584,7 +591,7 @@ def working_memory(self): for (kind, provenance), answers in sources.items()), False)) return tuple(groups) - async def find(self, roots: tuple[ContextNode, ...], query: str, *, limit=100): + async def find(self, roots: tuple[ContextNode, ...], query: str, *, limit: int=100): """Bound result widgets while reading the selected original public sources.""" normalized = query.casefold() matches = [] @@ -595,7 +602,7 @@ async def find(self, roots: tuple[ContextNode, ...], query: str, *, limit=100): return tuple(matches) return tuple(matches) - async def _request(self, action, **parameters): + async def _request(self, action: str, **parameters): from agent_comms.runtime import RuntimeConnection, socket_path owner = await Coordination.run_worker(partial(self.service.registry.require, self.owner.name)) @@ -620,7 +627,7 @@ async def acquire_core(self): raise ValueError("Core context belongs to another thread incarnation") return replace(self, core=AcquiredCoreInspection(context)) - async def core_source(self, context, position, source): + async def core_source(self, context: TurnContext, position, source): def request(): return dict(owner=FieldCodec.encode(context.thread), segment=position, manifest=FieldCodec.encode(context.segments[position].manifest(0)), @@ -629,7 +636,7 @@ def request(): payload = await self._request("context_core_source", **parameters) return await Coordination.run_worker(partial(FieldCodec.decode, ContextSourceText, payload)) - async def current_source(self, context, position, source): + async def current_source(self, context: NativeContextData, position, source): payload = await self._request("context_source", observation=FieldCodec.encode(context.observation()), segment=position, source=FieldCodec.encode(source)) @@ -638,22 +645,22 @@ async def current_source(self, context, position, source): async def recorded_source(self, manifest, position, source): payload = await self._request("context_reference", record=FieldCodec.encode(self.page.reference(manifest)), - turn=FieldCodec.encode(manifest.turn), request_id=manifest.require_request_id(), + turn=FieldCodec.encode(manifest.turn), request_id=manifest.request_id.require_id(), segment=position, source=FieldCodec.encode(source)) return FieldCodec.decode(ContextSourceText, payload) - async def recorded_segment(self, manifest, position, contributors=()): + async def recorded_segment(self, manifest: ContextManifest, position: int, contributors: tuple[int, ...]=()): payload = await self._request("context_recorded_segment", record=FieldCodec.encode(self.page.reference(manifest)), - turn=FieldCodec.encode(manifest.turn), request_id=manifest.require_request_id(), + turn=FieldCodec.encode(manifest.turn), request_id=manifest.request_id.require_id(), segment=position, contributors=contributors) return FieldCodec.decode(ContextSourceText, payload) - async def correct_annotation(self, source, label, answer, worktree): + async def correct_annotation(self, source: RecordedSegmentNode, label: ModelLabel, answer: type[SpanAnswer], worktree: Path): payload = await self._request("context_annotation_correction", record=FieldCodec.encode(self.page.reference(source.manifest)), turn=FieldCodec.encode(source.manifest.turn), - request_id=source.manifest.require_request_id(), segment=source.position, + request_id=source.manifest.request_id.require_id(), segment=source.position, contributors=source.contributors, label=FieldCodec.encode(label), answer=FieldCodec.encode(answer), worktree=str(worktree)) return FieldCodec.decode(ModelLabel, payload) @@ -684,12 +691,12 @@ def read(): return self.saved_manifest(manifest).public_source_text(self.service, position, source) return await Coordination.run_worker(read) - async def recorded_segment(self, manifest, position, contributors=()): + async def recorded_segment(self, manifest, position, contributors: tuple[int, ...]=()): def read(): return self.saved_manifest(manifest).selected_segment(position, contributors) segment = await Coordination.run_worker(read) - async def unavailable(original): + async def unavailable(original) -> str: return "Public SDK text was not retained in this saved record; no live preview is available." text = await segment.public_text(unavailable) @@ -734,7 +741,7 @@ def receive_inspection(self, acquired) -> InspectionState: def inspection_differs(self, inspection) -> bool: return True - def needs_native(self, inspection, force) -> bool: + def needs_native(self, inspection, force: bool) -> bool: return True def same_source(self, inspection) -> bool: @@ -764,7 +771,7 @@ def contains_native(self, matches) -> bool: return False @property - def status(self): + def status(self) -> str: return "No managed thread context available" @@ -814,7 +821,7 @@ def same_source(self, inspection) -> bool: def inspection_differs(self, inspection) -> bool: return not self.same_source(inspection) or inspection.changed_since(self.inspection) - def needs_native(self, inspection, force) -> bool: + def needs_native(self, inspection, force: bool) -> bool: return force or self.inspection_differs(inspection) def present(self, consumer) -> None: @@ -850,7 +857,7 @@ async def find(self, query, selected): sources = await Coordination.run_worker(partial(roots, self.current_roots)) return await self.inspection.find(sources, query) - def native_matches(self, current): + def native_matches(self, current) -> bool: return not current.contains_native(bool) def presentation_current(self, captured) -> bool: @@ -860,7 +867,7 @@ def presentation_current(self, captured) -> bool: and captured.native_matches(self)) @property - def status(self): + def status(self) -> str: return f"{self.thread_identity.thread_name()} · {self.inspection.core.status}\nReading native context…" @@ -874,7 +881,7 @@ def present_acquired(self, previous, consumer) -> None: self.present(consumer) @property - def status(self): + def status(self) -> str: return (f"{self.thread_identity.thread_name()} · stopped · saved context records\n" "Live Core/native preview unavailable; no owner process is required.") @@ -893,7 +900,7 @@ def receive_inspection(self, acquired) -> InspectionState: return NativeInspection(acquired.inspection, acquired.revision, self.native) return acquired - def needs_native(self, inspection, force) -> bool: + def needs_native(self, inspection, force: bool) -> bool: return force or not self.same_source(inspection) def with_native(self, inspection, native) -> InspectionState: @@ -917,7 +924,7 @@ def native_matches(self, current): return current.contains_native(lambda native: native is self.native) @property - def status(self): + def status(self) -> str: return (f"{self.thread_identity.thread_name()} · {self.inspection.core.status}; native base available\n" f"Segment counts: estimates ({self.native.counter}) · provider totals unavailable") @@ -932,5 +939,5 @@ def receive_inspection(self, acquired) -> InspectionState: return acquired @property - def status(self): + def status(self) -> str: return f"{self.thread_identity.thread_name()} · {self.inspection.core.status}\nNative detail unavailable: {self.error}" diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index f26e29840..9c5164f02 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -7,12 +7,11 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace from itertools import islice -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Awaitable, Callable, ClassVar from weakref import ReferenceType, ref from agent_comms.historical_views import ConversationObservation from agent_comms.sent_message import OutgoingMessageReference -from agent_comms.transcript_events import TranscriptEvent from agent_comms.transcripts import ( TranscriptCursor, TranscriptPage, @@ -65,7 +64,7 @@ def prepare(cls, page: TranscriptPage) -> PreparedTranscriptPage: return cls.measured(page, fragments) @classmethod - def measured(cls, page, fragments, **view): + def measured(cls, page, fragments: tuple[TranscriptFragment, ...], **view): coverage = frozenset(reference for fragment in fragments for reference in fragment.outgoing_sources) prepared = cls(page, fragments, 0, outgoing_coverage=coverage, **view) @@ -76,7 +75,7 @@ def measured(cls, page, fragments, **view): def source_payload_bytes(self) -> int: return sum(256 + 4 * event.text_size for event in self.page.events) - def measure_sources(self): + def measure_sources(self) -> None: """The page owns its events; each fragment owns only its acquired rows.""" self.retained_bytes = (256 + 64 * len(self.outgoing_coverage) + self.source_payload_bytes @@ -92,7 +91,7 @@ def project(self, fragments): return self.for_view() return type(self).measured(self.page, fragments) - def configure(self, *, batch_size): + def configure(self, *, batch_size: int) -> None: if self.fragment_range is None: self.set_fragment_range(slice(max(0, len(self.fragments) - batch_size), len(self.fragments))) @@ -365,7 +364,7 @@ async def latest(self, prepared: PreparedTranscriptPage) -> PreparedTranscriptPa return (await self.get(PageRequest(OLDER_END, self.through))).for_view() @abstractmethod - def prefix(self, through) -> PreparedPageSource: ... + def prefix(self, through: TranscriptCursor) -> PreparedPageSource: ... async def prepare_next(self, page: TranscriptPage, end: PageEnd) -> None: if self.has_more(end, page): @@ -393,7 +392,7 @@ async def project(self, page: PreparedTranscriptPage, runtime: PreparationRuntim class CategoryProjection(TranscriptPageProjection): selected: frozenset[type[MessageCategory]] - def fragments_for(self, page): + def fragments_for(self, page: PreparedTranscriptPage): return page.fragments async def project(self, page: PreparedTranscriptPage, runtime: PreparationRuntime) -> PreparedTranscriptPage: @@ -430,7 +429,7 @@ def execute(self) -> PreparedTranscriptPage: class TranscriptNoProgress(ValueError): """A page read returned no new edge while its end still claimed more.""" - def __init__(self, request: PageRequest, through, page: TranscriptPage) -> None: + def __init__(self, request: PageRequest, through: TranscriptCursor | None, page: TranscriptPage) -> None: super().__init__( f"Transcript history made no cursor progress: {type(request.end).__name__} " f"from {request.cursor!r} through {through!r} returned before={page.before!r} " @@ -523,7 +522,7 @@ async def _prepare(self, request: PageRequest) -> None: class SavedPrefix(SavedHistory): - def __init__(self, earlier, loader, through, runtime): + def __init__(self, earlier: SavedHistory, loader: Callable[..., Awaitable[TranscriptPage]], through, runtime: PreparationRuntime) -> None: super().__init__(loader, through, runtime) self.earlier = earlier @@ -539,7 +538,7 @@ def read_task(self, request): async def _get(self, request): return await request.end.edge((self.earlier.get, super()._get))(request) - async def _prepare(self, request): + async def _prepare(self, request) -> None: await request.end.edge((self.earlier.prepare, super()._prepare))(request) @@ -562,7 +561,7 @@ class ClosedHistory(ReplaceablePageSource): revision = ConversationObservation() - def __init__(self, through, runtime): + def __init__(self, through: TranscriptCursor, runtime: PreparationRuntime) -> None: super().__init__() self.through, self.runtime = through, runtime self.scope = PreparationScope() @@ -611,7 +610,7 @@ async def read_only(self) -> bool: async def update_mentions(self, view) -> None: return - async def status_text(self, *, read_only): + async def status_text(self, *, read_only) -> str: return "Conversation source is not ready" def has_more(self, end, page) -> bool: @@ -635,7 +634,7 @@ def replacement(self, through) -> ClosedHistory: async def _get(self, request): raise ValueError("This transcript has no saved-source reader") - async def _prepare(self, request): + async def _prepare(self, request) -> None: # No saved read can be speculated for a retained-only range. return diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 6f0833c56..df2a69a78 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -29,6 +29,7 @@ TranscriptPublicationState, ) from textual.await_complete import AwaitComplete +from textual.widget import Widget from textual.worker import Worker, WorkerCancelled from toad.core.events import CoreEventStream @@ -62,7 +63,7 @@ class BoundFrame: owner: TranscriptPresentation generation: int @classmethod - def capture(cls, binding, owner, *args, **extra): + def capture(cls, binding: BoundView, owner, *args, **extra): return cls(binding, owner, owner.generation, *args, **extra) def current(self): @@ -70,17 +71,17 @@ def current(self): and self.owner.generation == self.generation and self.binding.current()) - def painted(self, cursor): + def painted(self, cursor) -> None: if self.current(): self.binding.view.retire_loading_splash() self.record(cursor) - def observed(self, cursor): + def observed(self, cursor) -> None: """A changed source cursor is not proof that its page has painted.""" if self.current(): self.record(cursor) - def record(self, cursor): + def record(self, cursor) -> None: self.owner.displayed_cursor = cursor view = self.binding.view view.app.history_displayed() @@ -209,7 +210,7 @@ async def publish_draft(self, snapshot: TranscriptDraftUpdate) -> bool: self.owner.current_read = Published(snapshot.identity) return True - def show_draft(self, snapshot) -> None: + def show_draft(self, snapshot: TranscriptDraftUpdate | TranscriptSnapshotUpdate) -> None: draft = snapshot.draft widget = self.binding.view.cli_draft if self.binding.contents.children[-1] is not widget: @@ -249,7 +250,7 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: class ObservedSourcePublication(CanonicalSourcePublication): - def __init__(self, binding, owner, generation, presentation): + def __init__(self, binding, owner, generation, presentation) -> None: super().__init__(binding, owner, generation) self.presentation = presentation self.agent_binding = binding.view.agent_binding @@ -265,13 +266,13 @@ async def publish_applied(self) -> bool: class SourcePublicationRequests: """One running read and one coalesced original request, never model state.""" - def __init__(self, owner: TranscriptPresentation): + def __init__(self, owner: TranscriptPresentation) -> None: self.owner = owner self.pending: deque[TranscriptPublication] = deque(maxlen=1) self.worker: Worker[None] | None = None self.observation = ResourceRelease() - def request(self, kind: type[TranscriptPublication], *args: object) -> None: + def request(self, kind: type[TranscriptPublication], *args) -> None: publication = self.owner.binding.capture(self.owner, kind, *args) if publication is None: return @@ -319,7 +320,7 @@ def cancel(self) -> Worker[None] | None: class CommitPublication(CanonicalSourcePublication): - def __init__(self, binding, owner, generation): + def __init__(self, binding, owner, generation) -> None: super().__init__(binding, owner, generation) self.plan = commit_plan(binding.window) @@ -407,10 +408,10 @@ def publication_current(): class ViewBinding: """The native view's binding and its view-dependent operations.""" - def matches(self, view: object) -> TypeGuard[Conversation]: + def matches(self, view) -> TypeGuard[Conversation]: return False - def bound_to(self, view): + def bound_to(self, view: Conversation): return view.transcript_binding() @property @@ -419,13 +420,13 @@ def thread_scope(self): reports_coverage = False - def covers_outgoing(self, message): + def covers_outgoing(self, message) -> bool: return False - def covers_input(self, started: InputStartedUpdate): + def covers_input(self, started: InputStartedUpdate) -> bool: return False - def close_source(self): + def close_source(self) -> None: pass def replace_source(self): @@ -435,19 +436,19 @@ def replace_source(self): def history(self) -> TranscriptHistory: raise ValueError("Saved history requires a bound view") - def observe_thread(self, owner): + def observe_thread(self, owner: TranscriptPresentation) -> None: pass - def capture(self, owner, kind, *args): + def capture(self, owner: TranscriptPresentation, kind, *args): return None - def painted(self, owner, cursor): + def painted(self, owner, cursor) -> None: pass - def observed(self, owner, cursor): + def observed(self, owner: TranscriptPresentation, cursor: TranscriptCursor) -> None: pass - def request(self, owner): + def request(self, owner: TranscriptPresentation) -> Worker[bool] | None: return None def cancel_workers(self): @@ -456,13 +457,13 @@ def cancel_workers(self): def covered(self, owner, coverage, history): return AwaitComplete.nothing() - def reset_reader(self): + def reset_reader(self) -> None: pass - async def restore_documents(self, owner): + async def restore_documents(self, owner) -> None: pass - async def restore(self, owner): + async def restore(self, owner) -> None: pass @@ -476,7 +477,7 @@ class Moment(BoundFrame): source: SessionView @classmethod - def capture(cls, binding, owner, **extra): + def capture(cls, binding: BoundView, owner, **extra): return super().capture(binding, owner, source=binding.view.query_ancestor(SessionView), **extra) @@ -492,7 +493,7 @@ def current(self): return (super().current() and not self.owner.closed and self.binding.window.scroll_revision == self.revision) - def refresh(self, publication): + def refresh(self, publication) -> None: if not self.current(): return if publication is None: @@ -501,11 +502,11 @@ def refresh(self, publication): else: self.owner.source_requests.submit(publication) - def __init__(self, view: Conversation): + def __init__(self, view: Conversation) -> None: self.view, self.window, self.contents = view, view.window, view.contents self._history, = self.contents.query_children(TranscriptHistory) - def matches(self, view: object) -> TypeGuard[Conversation]: + def matches(self, view) -> TypeGuard[Conversation]: return self.view is view def bound_to(self, view): @@ -530,7 +531,7 @@ def current(self): def history(self) -> TranscriptHistory: return self._history - def observe_thread(self, owner): + def observe_thread(self, owner) -> None: scope = self.thread_scope if scope is not None: view = self.view @@ -547,7 +548,7 @@ def covers_outgoing(self, message): def covers_input(self, started: InputStartedUpdate): return self.history.covers_input(started) - def close_source(self): + def close_source(self) -> None: self.history.close() def replace_source(self): @@ -555,7 +556,7 @@ def replace_source(self): history = self.history retirement = history.retire_source() - async def replace(): + async def replace() -> None: await retirement replacement = TranscriptHistory.empty(view=self.view, notification_view=self.contents) self._history = replacement @@ -568,7 +569,7 @@ async def replace(): return AwaitComplete(replace()) - async def restore_documents(self, owner): + async def restore_documents(self, owner) -> None: if not self.view.is_attached or not owner.documents: return moment = self.Moment.capture(self, owner) @@ -579,7 +580,7 @@ async def restore_documents(self, owner): if moment.current(): owner.documents = () - async def restore(self, owner): + async def restore(self, owner) -> None: view = self.view view.resume_retained_history() if not owner.readable: @@ -596,7 +597,7 @@ async def restore(self, owner): publication = self.capture(owner, CanonicalSourcePublication) view.call_after_refresh(partial(moment.refresh, publication)) - async def reveal_retained(self, owner, history): + async def reveal_retained(self, owner, history) -> None: view = self.view if not view.is_attached: return @@ -609,11 +610,11 @@ def capture(self, owner, kind, *args): return None return kind.capture(self, owner, *args) - def painted(self, owner, cursor): + def painted(self, owner, cursor) -> None: frame = BoundFrame.capture(self, owner) self.view.call_after_refresh(partial(frame.painted, cursor)) - def observed(self, owner, cursor): + def observed(self, owner, cursor) -> None: frame = BoundFrame.capture(self, owner) self.view.call_after_refresh(partial(frame.observed, cursor)) @@ -645,13 +646,13 @@ def covered(self, owner, coverage, history): protected = protected_blocks(view, candidates) retirement = owner.retire_presentations(contents, view.output, [child for child in candidates if child not in protected]) - async def complete(): + async def complete() -> None: await retirement view.query_one(SessionDetails)._refresh_summary() return AwaitComplete(complete()) return AwaitComplete.nothing() - def reset_reader(self): + def reset_reader(self) -> None: self.window.reader_position = TailPosition() @@ -846,7 +847,7 @@ async def suspend(self) -> None: pass - def retire_presentations(self, contents, output, candidates) -> AwaitComplete: + def retire_presentations(self, contents, output, candidates: list[Widget]) -> AwaitComplete: """Withdraw covered native paint now; join teardown outside source locks. CommitEvidence has already selected these exact resources. Their native diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 499646dd4..76937840b 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -4,27 +4,32 @@ import asyncio from dataclasses import dataclass, field from pathlib import Path +from typing import Callable from acp.exceptions import RequestError +from agent_comms.context_store import ContextManifestCursor +from agent_comms.coordinator import Coordination +from agent_comms.mro_dispatch import handles +from agent_comms.route_selection import RouteSelection from textual import on, work from textual.containers import Horizontal, Vertical from textual.widgets import Button, Input, Static, Tree -from toad.widgets.process_text_area import ProcessTextArea +from textual.widgets._tree import TreeNode from textual.worker import Worker, WorkerCancelled, WorkerState, get_current_worker -from agent_comms.mro_dispatch import handles -from agent_comms.coordinator import Coordination -from agent_comms.context_store import ContextManifestCursor from toad.core.context_inspection import ( - ContextNode, DetachedInspection, HoldingInspection, InspectionState, + ContextNode, + DetachedInspection, + HoldingInspection, + InspectionState, ) from toad.core.events import CoordinationObserved, SessionSelected from toad.core_event_carrier import CoreEventMessage -from agent_comms.route_selection import RouteSelection -from toad.thread_identity import SessionIdentity, ThreadIdentity -from toad.widgets.thread_identity import ShowsThreadIdentity from toad.screens.session_view import SessionView +from toad.thread_identity import SessionIdentity, ThreadIdentity +from toad.widgets.process_text_area import ProcessTextArea from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarVisibilityObserver +from toad.widgets.thread_identity import ShowsThreadIdentity @dataclass @@ -38,7 +43,7 @@ class ContextTreeIntent: query: str = "" cursor: ContextManifestCursor | None = None - def select_page(self, cursor): + def select_page(self, cursor) -> bool: if cursor is None: return False self.cursor = cursor @@ -48,11 +53,11 @@ def select_page(self, cursor): def search_current(self, query, selected): return self.query == query and self.selected is selected - def rebind(self, original, model): + def rebind(self, original, model) -> None: if self.selected is original: self.selected = model - def restore_detail(self, resolve, show_detail): + def restore_detail(self, resolve, show_detail) -> bool: if self.selected is None: return False node = resolve(self.selected.key) @@ -66,7 +71,7 @@ def restore_detail(self, resolve, show_detail): class ContextTree(Tree[ContextNode]): """Native nodes, disclosure and cursor share the Tree's mounted lifetime.""" - def __init__(self, show_detail, show_placeholder): + def __init__(self, show_detail: Callable[..., Worker[None]], show_placeholder) -> None: self.show_detail = show_detail self.show_placeholder = show_placeholder self.context_nodes = {} @@ -82,10 +87,10 @@ def clear(self): self.context_nodes.clear() return super().clear() - def on_unmount(self): + def on_unmount(self) -> None: self.context_nodes.clear() - def present(self, groups): + def present(self, groups) -> None: # Reconcile the original TreeNodes so Tree._build rebases its cursor. with self.prevent(Tree.NodeExpanded, Tree.NodeCollapsed, Tree.NodeSelected, Tree.NodeHighlighted): @@ -102,7 +107,7 @@ def present(self, groups): self._reconcile(group, models) self.restore_detail("Select a context segment to inspect.") - def search_results(self, matches, description, query): + def search_results(self, matches, description: str, query) -> None: with self.prevent(Tree.NodeExpanded, Tree.NodeCollapsed, Tree.NodeSelected): self.clear() self.root.set_label(f"{description} · {query}") @@ -111,7 +116,7 @@ def search_results(self, matches, description, query): self._add(self.root, model) self.restore_detail("Select a matching original source to read its full public text.") - def _reconcile(self, parent, models): + def _reconcile(self, parent, models) -> None: previous = {node.data.key: node for node in parent.children} for index, model in enumerate(models): node = previous.pop(model.key, None) @@ -135,14 +140,14 @@ def _reconcile(self, parent, models): for node in previous.values(): self._retire(node) - def _retire(self, node): + def _retire(self, node) -> None: for child in tuple(node.children): self._retire(child) if self.owns_node(node): self.context_nodes.pop(node.data.key) node.remove() - def restore_detail(self, placeholder): + def restore_detail(self, placeholder: str) -> None: if not self.intent.restore_detail(self.resolve_reader, self.show_detail): self.show_placeholder(placeholder) @@ -161,7 +166,7 @@ def resolve_reader(self, key): break return node - def _add(self, parent, model, *, before=None): + def _add(self, parent: TreeNode[ContextNode], model, *, before=None): node = parent.add(model.label, model, before=before, allow_expand=True, expand=model.key in self.intent.expanded) self.context_nodes[model.key] = node @@ -176,7 +181,7 @@ def owns_node(self, node): return False return self.is_attached and self.context_nodes.get(node.data.key) is node - def _expand(self, node): + def _expand(self, node) -> None: if not self.owns_node(node) or node.children: return # TreeNode owns whether this native disclosure still admits expansion. @@ -185,7 +190,7 @@ def _expand(self, node): self._add(node, model) node.allow_expand = bool(node.children) - def _toggle_node(self, node): + def _toggle_node(self, node) -> None: # Capture disclosure navigation before collapsing a selected descendant. # Data-less groups are native rows too; they don't select detail sources. with self.prevent(Tree.NodeHighlighted): @@ -193,13 +198,13 @@ def _toggle_node(self, node): super()._toggle_node(node) @on(Tree.NodeExpanded, "#context-tree") - def node_expanded(self, event): + def node_expanded(self, event) -> None: if self.owns_node(event.node): self.intent.expanded.add(event.node.data.key) self._expand(event.node) @on(Tree.NodeCollapsed, "#context-tree") - def node_collapsed(self, event): + def node_collapsed(self, event) -> None: if self.owns_node(event.node): self.intent.expanded.discard(event.node.data.key) @@ -221,9 +226,9 @@ class ContextExplorer(ShowsThreadIdentity, SidebarVisibilityObserver, Vertical): ContextExplorer > TextArea.-maximized { height: 1fr; width: 1fr; } """ BINDINGS = [("r", "refresh", "Refresh context")] - def __init__(self, *, intents: dict[SessionIdentity, ContextTreeIntent]): + def __init__(self, *, intents: dict[SessionIdentity, ContextTreeIntent]) -> None: super().__init__() - self.state = DetachedInspection() + self.state: InspectionState = DetachedInspection() self._intents = intents self.search_field = Input(placeholder="Search current context or selected recorded request · Enter", id="context-search") @@ -251,13 +256,13 @@ def compose(self): yield Input(placeholder="Export to a new text file · path", id="context-export-path") yield Button("Export selected text + source", id="context-export") - def on_mount(self): + def on_mount(self) -> None: self.observe_core(self.app.coordination_access.events) self.observe_core(self.app.events) self.call_after_refresh(self.action_refresh) @handles(CoordinationObserved, SessionSelected) - def context_changed(self, message: CoreEventMessage): + def context_changed(self, message: CoreEventMessage) -> None: self._observed() def presentation_visible(self): @@ -265,13 +270,13 @@ def presentation_visible(self): and not self.query_ancestor(SideBar).collapsed and not self.query_ancestor(SideBarCollapsible).collapsed) - def on_show(self): + def on_show(self) -> None: self.call_after_refresh(self._observed) - def sidebar_visibility_changed(self): + def sidebar_visibility_changed(self) -> None: self._observed() - def _observed(self, _value=None): + def _observed(self, _value=None) -> None: if not self.presentation_visible(): return # Invalidation does not replace an original read still in flight. @@ -282,12 +287,12 @@ def _observed(self, _value=None): self._read() @on(Worker.StateChanged) - def context_read_finished(self, event): + def context_read_finished(self, event) -> None: if (event.worker.node is self and event.worker.group == "context-read" and event.state == WorkerState.SUCCESS): self._observed() - def on_unmount(self): + def on_unmount(self) -> None: self.state = DetachedInspection() def identity_changed(self, identity: SessionIdentity) -> None: @@ -299,7 +304,7 @@ def identity_changed(self, identity: SessionIdentity) -> None: self._show_placeholder("No context selected.") self.action_refresh() - def action_refresh(self): + def action_refresh(self) -> None: if self.presentation_visible(): self._read(force=True) @@ -307,17 +312,17 @@ def _reading(self, identity: SessionIdentity): return (self.is_attached and not get_current_worker().is_cancelled and self.thread_view.thread_identity == identity) - def _working(self, group): + def _working(self, group: str): return any(worker.node is self and worker.group == group and not worker.is_finished for worker in self.workers) @work(group="context-read", exclusive=True) - async def _read(self, *, force=False): + async def _read(self, *, force: bool=False) -> None: await self.thread_view.thread_identity.inspect_owner( lambda identity: self._read_owner(identity, force=force), self.query_one(".context-status", Static).update) - async def _read_owner(self, identity: ThreadIdentity, *, force=False): + async def _read_owner(self, identity: ThreadIdentity, *, force: bool=False): status = self.query_one(".context-status", Static) access = self.app.coordination_access previous = self.state @@ -337,7 +342,7 @@ async def _read_owner(self, identity: ThreadIdentity, *, force=False): except asyncio.CancelledError: raise - def _inspection_acquired(self, previous, current, force): + def _inspection_acquired(self, previous: InspectionState, current, force: bool) -> None: self.query_one("#context-older", Button).disabled = not current.inspection.page.has_older() self.query_one("#context-newer", Button).disabled = not current.inspection.page.has_newer() if previous.inspection_differs(current.inspection): @@ -363,19 +368,20 @@ async def _read_native(self, captured: HoldingInspection): if self._reading(captured.thread_identity): self._native_acquired(self.state.with_native(captured.inspection, native)) - def _native_acquired(self, updated): + def _native_acquired(self, updated: InspectionState) -> None: if updated is not self.state: self.state = updated updated.present(self._present) self.query_one(".context-status", Static).update(updated.status) - def _show_placeholder(self, placeholder): + def _show_placeholder(self, placeholder: str) -> None: detail = self.query_one(ProcessTextArea) detail.load_text_prepared(placeholder) @on(Button.Pressed, "#context-correct") - def action_correct(self, event): + def action_correct(self, event) -> None: from functools import partial + from toad.widgets.comms_menu import show_target_menu event.stop() @@ -393,7 +399,7 @@ def action_correct(self, event): for answer in answers}) @work(group="context-correction", exclusive=True) - async def _correct_annotation(self, model, answer): + async def _correct_annotation(self, model, answer) -> None: if not self._selected(model): return worktree = self.query_ancestor(SessionView).project_root @@ -403,7 +409,7 @@ async def _correct_annotation(self, model, answer): self._read() @work(group="context-presentation", exclusive=True) - async def _present(self, captured: HoldingInspection): + async def _present(self, captured: HoldingInspection) -> None: if self.intent.query: self._search(captured, self.intent.query, self.intent.selected) return @@ -420,24 +426,24 @@ async def _present(self, captured: HoldingInspection): self.query_one(ContextTree).present(groups) @on(Button.Pressed, "#context-older") - def older_requests(self, event): + def older_requests(self, event) -> None: event.stop() self.state.present(self._older_requests) - def _older_requests(self, current): + def _older_requests(self, current) -> None: if self.intent.select_page(current.inspection.page.older): self._change_page() @on(Button.Pressed, "#context-newer") - def newer_requests(self, event): + def newer_requests(self, event) -> None: event.stop() self.state.present(self._newer_requests) - def _newer_requests(self, current): + def _newer_requests(self, current) -> None: if self.intent.select_page(current.inspection.page.newer): self._change_page() - def _change_page(self): + def _change_page(self) -> None: # Destination selection ends the acquired page's lifetime now, not # when the replacement read finishes. Unacquired inspection states # cannot publish old groups, searches or native results. @@ -453,18 +459,18 @@ def _change_page(self): self._read() @on(Input.Changed, "#context-search") - def query_changed(self, event): + def query_changed(self, event) -> None: event.stop() self.intent.query = event.value @on(Input.Submitted, "#context-search") @on(Button.Pressed, "#context-find") - def action_search(self, event): + def action_search(self, event) -> None: event.stop() self.state.present(self._present) @work(group="context-search", exclusive=True) - async def _search(self, captured: HoldingInspection, query, selected): + async def _search(self, captured: HoldingInspection, query: str, selected: ContextNode | None) -> None: status = self.query_one(".context-status", Static) status.update("Searching original public context…") matches = await captured.find(query, selected) @@ -474,13 +480,13 @@ async def _search(self, captured: HoldingInspection, query, selected): self.query_one(ContextTree).search_results(matches, description, query) status.update(f"{len(matches)} matching sources · first 100 shown · {description}") - def _searching(self, captured, query, selected): + def _searching(self, captured: HoldingInspection, query, selected): return (self._reading(captured.thread_identity) and self.state.presentation_current(captured) and self.intent.search_current(query, selected)) @on(Button.Pressed, "#context-read-full") @work(group="context-full-read", exclusive=True) - async def action_read_full(self): + async def action_read_full(self) -> None: node = self.query_one(ContextTree).cursor_node if not self.query_one(ContextTree).owns_node(node): return @@ -497,7 +503,7 @@ async def action_read_full(self): @on(Button.Pressed, "#context-copy") @work(group="context-copy", exclusive=True) - async def action_copy(self): + async def action_copy(self) -> None: node = self.query_one(ContextTree).cursor_node if self.query_one(ContextTree).owns_node(node): model = node.data @@ -508,7 +514,7 @@ async def action_copy(self): @on(Button.Pressed, "#context-export") @work(group="context-export", exclusive=True) - async def action_export(self): + async def action_export(self) -> None: node = self.query_one(ContextTree).cursor_node if not self.query_one(ContextTree).owns_node(node): return @@ -522,7 +528,7 @@ async def action_export(self): self.notify(f"Exported selected public text and source to {destination}") @on(Tree.NodeHighlighted, "#context-tree") - def node_highlighted(self, event): + def node_highlighted(self, event) -> None: event.stop() if self.query_one(ContextTree).owns_node(event.node): if self.intent.selected is not event.node.data: @@ -534,7 +540,7 @@ def _selected(self, model): return self.query_one(ContextTree).selected(model) if self.is_attached else False @work(group="context-detail", exclusive=True) - async def _show_detail(self, model): + async def _show_detail(self, model) -> None: # Publication belongs to this exact selected original tree model, not # a reusable string key shared by another thread or context snapshot. if not self._selected(model): diff --git a/src/toad/widgets/presentation_window.py b/src/toad/widgets/presentation_window.py index 74e42f524..1dd2798ac 100644 --- a/src/toad/widgets/presentation_window.py +++ b/src/toad/widgets/presentation_window.py @@ -1,17 +1,18 @@ """Shared budgets for a source-backed window of native widget presentation.""" -from dataclasses import dataclass, fields, replace -from collections import deque -from agent_comms.transcripts import TranscriptCursor -from agent_comms.message_page import PageBounds from abc import ABC, abstractmethod +from collections import deque from collections.abc import Iterable -from itertools import zip_longest +from dataclasses import dataclass, fields, replace from functools import partial +from itertools import zip_longest from math import ceil -from operator import gt, ge -from time import monotonic, get_clock_info -from typing import Annotated, ClassVar, TYPE_CHECKING, get_args +from operator import ge, gt +from time import get_clock_info, monotonic +from typing import TYPE_CHECKING, Annotated, ClassVar, get_args + +from agent_comms.message_page import PageBounds +from agent_comms.transcripts import TranscriptCursor if TYPE_CHECKING: from agent_comms.transcripts import TranscriptPage @@ -76,7 +77,7 @@ def can_read_destination(self, pages: int) -> bool: def runway_rows(self, viewport_rows: int) -> int: return max(1, viewport_rows) * self.buffer_viewports - def fit(self, candidates, required, viewport_rows: int, max_bytes: int): + def fit(self, candidates, required: "tuple[Widget, ...]", viewport_rows: int, max_bytes: int): """Visible/protected native bodies first, then ordered bounded resources.""" kept = set(required) widgets = source_bytes = 0 @@ -163,7 +164,7 @@ def work_cursor[Cursor](self, through: Cursor) -> Cursor | None: return self.edge((None, through)) @abstractmethod async def load_page(self, loader, cursor: TranscriptCursor | None, through): ... - def progressed(self, page, cursor: TranscriptCursor) -> bool: + def progressed(self, page: "TranscriptPage", cursor: TranscriptCursor) -> bool: edge = self.edge((page.before, page.after)) start, stop = self.edge(((edge, cursor), (cursor, edge))) return cursor.accepts_source(edge) and not ( @@ -171,7 +172,7 @@ def progressed(self, page, cursor: TranscriptCursor) -> bool: @abstractmethod def wire_page(self, source, cursor, through): ... - def entry(self, length): + def entry(self, length: int): return self.edge((length - 1, 0)) async def project(self, source, initial, prepared): @@ -186,7 +187,7 @@ def fresh(self, history) -> bool: def accepts_velocity(self, velocity: float) -> bool: return velocity * self.step > 0 - async def walk(self, history, current, target, selected, start, rows_needed, limit): + async def walk(self, history, current, target, selected, start, rows_needed, limit: int): for index in range(start[0], self.edge((-1, len(history.pages))), self.step): if not current() or rows_needed <= 0 or limit <= 0: break @@ -207,7 +208,7 @@ def can_evict(self, overlay_visible) -> bool: def leave_tail(self, window) -> None: pass - def trim(self, view, count) -> None: + def trim(self, view, count: int) -> None: page = view.prepared page.set_fragment_range(slice(page.start + self.edge((count, 0)), page.stop - self.edge((0, count)))) view.range_changed() @@ -226,22 +227,22 @@ def wire_page(self, source, cursor, through): cursor.older(), source.tail_limit if cursor == through else source.page_limit)) - def advance_empty(self, edge, staged): + def advance_empty(self, edge, staged) -> None: edge.page = replace(edge.page, before=staged.page.before, has_older=staged.page.has_older) def edge(self, pages): return pages[0] - async def mount(self, history, view, pages): + async def mount(self, history, view, pages) -> None: await history.mount(view, before=self.edge(pages) if pages else history.newer) def eviction_order(self, history): return super().eviction_order(history) if history.follows_source_tail else (NEWER_END, self) - def can_evict(self, overlay_visible): + def can_evict(self, overlay_visible) -> bool: return not overlay_visible - def leave_tail(self, window): + def leave_tail(self, window) -> None: window.release_anchor() @@ -269,7 +270,7 @@ async def project(self, source, initial, prepared): has_older=initial.page.has_older)) return await super().project(source, initial, prepared) - def advance_empty(self, edge, staged): + def advance_empty(self, edge, staged) -> None: edge.page = replace(edge.page, after=staged.page.after, has_newer=staged.page.has_newer) def fresh(self, history): @@ -278,7 +279,7 @@ def fresh(self, history): def edge(self, pages): return pages[-1] - async def mount(self, history, view, pages): + async def mount(self, history, view, pages) -> None: await history.mount(view, before=history.newer) @@ -291,7 +292,7 @@ class PageRequest[Cursor = TranscriptCursor | None]: end: PageEnd cursor: Cursor - def progressed(self: PageRequest[TranscriptCursor | None], page: TranscriptPage) -> bool: + def progressed(self: PageRequest[TranscriptCursor | None], page: "TranscriptPage") -> bool: return self.cursor is None or self.end.progressed(page, self.cursor) @@ -308,7 +309,7 @@ def accepts_result(self, end) -> bool: """A running read may finish after input settles; it starts no next read.""" return True - def read_rows(self, preparation, history, end, runway: Runway) -> int: + def read_rows(self, preparation: "DirectionalPreparation", history, end, runway: Runway) -> int: """Stationary input never requests another source page.""" return 0 @@ -402,7 +403,7 @@ class DirectionalPreparation: The presentation budget bounds how much of that prediction can be retained. """ - def __init__(self, window): + def __init__(self, window) -> None: self.window = window self.position = 0.0 self.sampled_at = monotonic() @@ -496,7 +497,7 @@ def destination_batch(self, budget: PresentationBudget, viewport_rows: int) -> i # destination items may exceed the nominal working-set item budget. return max(budget.item_limit(0), viewport_rows) - def read_rows(self, history, end) -> int: + def read_rows(self, history, end: PageEnd) -> int: if not end.visible_more(history): return 0 runway = self.runway(history) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 67f3e3f17..69f4c7ed3 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -4,39 +4,44 @@ import asyncio from abc import ABC, abstractmethod -from dataclasses import dataclass from collections import Counter from collections.abc import Iterable +from contextlib import contextmanager +from dataclasses import dataclass from functools import wraps from itertools import islice -from contextlib import contextmanager from time import perf_counter_ns from typing import TYPE_CHECKING if TYPE_CHECKING: - from toad.widgets.comms_sidebar import CommsRow - from toad.comms_root import Observing, FailedObservation from toad.app import ToadApp + from toad.comms_root import FailedObservation, Observing + from toad.widgets.comms_sidebar import CommsRow +from agent_comms.display_order import ThreadSort +from agent_comms.mro_dispatch import handles +from agent_comms.ui_model.changes import Changes +from agent_comms.ui_model.sidebar import ChannelRowModel, SidebarModel +from agent_comms.ui_model.unread import UnreadPresentation +from textual._activation import PrimaryActivation +from textual._measurement import FLOW_HEIGHT, height_dependency from textual.app import ComposeResult from textual.binding import Binding -from textual.reactive import reactive from textual.containers import Vertical, VerticalGroup -from textual.widgets import Static -from textual.widget import Widget -from textual._activation import PrimaryActivation +from textual.geometry import Size from textual.layouts.vertical import VerticalLayout -from textual._measurement import FLOW_HEIGHT, height_dependency +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Static -from agent_comms.mro_dispatch import handles -from toad.core.events import SessionSelected, CoordinationObserved, ThreadRowStateChanged -from toad.core_event_carrier import CoreEventReceiver, CoreEventMessage -from agent_comms.ui_model.changes import Changes -from agent_comms.ui_model.sidebar import ChannelRowModel, SidebarModel -from agent_comms.ui_model.unread import UnreadPresentation -from agent_comms.display_order import ThreadSort -from toad.widgets.sidebar_viewport import SidebarHeader +from toad.core.events import ( + CoordinationObserved, + SessionSelected, + ThreadRowStateChanged, +) +from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.widgets.side_bar import SideBar +from toad.widgets.sidebar_viewport import SidebarHeader class UnpaintedUnread(UnreadPresentation): @@ -46,7 +51,7 @@ class UnpaintedUnread(UnreadPresentation): class UnpaintedChannel(ChannelRowModel): """An unobserved channel has no painted fields or known members.""" - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(name, "", "", UnpaintedUnread(), False, (), frozenset(), ThreadSort) @@ -88,7 +93,7 @@ class SidebarReaderLayout(VerticalLayout): """Native flow, preserving the viewport's reader before placements change.""" @height_dependency(FLOW_HEIGHT) - def arrange(self, parent, children, size, greedy=True): + def arrange(self, parent, children, size: Size, greedy: bool=True): placements = super().arrange(parent, children, size, greedy) parent.query_ancestor(TargetTree).preserve_reader(placements) return placements @@ -97,7 +102,7 @@ def arrange(self, parent, children, size, greedy=True): class RowReconciliation: """One sync's model membership and transferable native rows, never a retained cache.""" - def __init__(self, tree): + def __init__(self, tree: TargetTree) -> None: demand = Counter(key for key in tree.member_keys if tree.matches(key)) self.listed = frozenset(demand) self.sources = {} @@ -125,18 +130,18 @@ def synchronized(view, *args, **kwargs): return method(view, *args, **kwargs) return synchronized - def finish(self): + def finish(self) -> None: for group, keys in self.requests.items(): if any(self.sources.get(key) for key in keys): group.sync_members(frozenset(group.member_keys)) - def retains(self, key, group): + def retains(self, key: str, group: SidebarGroup) -> bool: return key in self.listed and group in self.sources.get(key, ()) - def has_spare(self, key): + def has_spare(self, key: str): return bool(self.sources.get(key)) - def take(self, key, destination): + def take(self, key: str, destination: SidebarGroup): sources = self.sources.get(key) if not sources: return None @@ -228,7 +233,7 @@ class SidebarGroup[RowT: "CommsRow"](KeyedRows, VerticalGroup): """The worker mounting pending rows after the following frames.""" def __init__(self, row, *, expanded: bool, controls=(), - disclosure_type=SidebarDisclosure, **kwargs): + disclosure_type=SidebarDisclosure, **kwargs) -> None: super().__init__(**kwargs) self.row = row self.rows: dict[str, RowT] = {} @@ -471,7 +476,7 @@ def apply_navigation(self, sidebar) -> None: class SidebarModelState(ABC): @abstractmethod - def shows_child(self, view, child: Widget) -> bool: + def shows_child(self, view: SidebarModelView, child: Widget) -> bool: """Whether this state's sidebar displays this direct child.""" def root(self): @@ -490,16 +495,16 @@ def serves(self, path) -> bool: def observes(self, observed: Observing) -> bool: return False - def unsubscribe(self, view) -> None: + def unsubscribe(self, view: SidebarModelView) -> None: pass def changes(self, changes): return changes - def apply(self, view, changes) -> None: + def apply(self, view: SidebarModelView, changes) -> None: pass - def present(self, view): + def present(self, view: SidebarModelView): view.app.coordination_access.show_sidebar(view, False) return self @@ -585,7 +590,7 @@ class SidebarModelView(CoreEventReceiver, Widget): while this one is hidden are applied in one pass when it shows again. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.unavailable = Static("", markup=False) @@ -738,7 +743,7 @@ def reconciliation_tree(self): def selection_state(self): raise NotImplementedError - def selection_for(self, row): + def selection_for(self, row: CommsRow): raise NotImplementedError def accepts_selection(self) -> bool: @@ -769,7 +774,7 @@ def remember_row(self, row) -> None: state.selected = self.selection_for(row) state.selected_targets = () - def pointer_select(self, row, *, control=False, shift=False, menu=False) -> None: + def pointer_select(self, row, *, control: bool=False, shift: bool=False, menu: bool=False) -> None: if not self.accepts_selection(): return rows = self.rows @@ -844,7 +849,7 @@ def move_cursor(self, direction: int) -> None: row = rows[max(0, min(len(rows) - 1, index + direction))] self.focus_row(row) - def focus_row(self, row) -> None: + def focus_row(self, row: CommsRow) -> None: row.scroll_visible(animate=False, immediate=True) self.screen.set_focus(row, scroll_visible=False) From 4e4ebcdaf779e762f054a1fcc4534a43bd8e3848 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 07:03:00 -0400 Subject: [PATCH 2/4] Type publication matches and remove untyped request forwarding Match against the existing ConversationSessionBinding parent. Request takes a constructor with the captured binding, owner and generation; observed constructors bind their presentation explicitly at the caller. Declare the existing presentation-observation capability on AgentBinding rather than erase the view type. --- src/toad/agent_binding.py | 6 ++++++ src/toad/transcript_publication.py | 23 ++++++++++++++--------- src/toad/widgets/conversation.py | 10 +++++++--- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index ede6da689..59e33b1b0 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from functools import cached_property +from agent_comms.thread_presentation import ThreadPresentation from textual.content import Content from toad.agent_presentation import LocalAgentPresentation from toad.acp.agent_configuration import AgentConfiguration @@ -9,6 +10,11 @@ class AgentBinding(ABC): + @abstractmethod + async def observe_thread_presentation( + self, presentation: ThreadPresentation | None + ) -> None: ... + def thread_read(self): from toad.acp.comms_updates import NoPendingThreadRead return NoPendingThreadRead() diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index df2a69a78..fc0d9df6a 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -4,6 +4,7 @@ import asyncio from abc import ABC, abstractmethod from collections import deque +from collections.abc import Callable from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from functools import partial @@ -22,6 +23,7 @@ ) from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.transcript_events import TranscriptCoverage +from agent_comms.thread_presentation import ThreadPresentation from agent_comms.transcripts import TranscriptCursor, TranscriptPage from agent_comms.ui_model.observation import ( NotPublished, @@ -54,7 +56,7 @@ if TYPE_CHECKING: from toad.screens.session_view import SessionOrigin - from toad.widgets.conversation import Conversation + from toad.widgets.conversation import Conversation, ConversationSessionBinding @dataclass(frozen=True) @@ -250,7 +252,8 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: class ObservedSourcePublication(CanonicalSourcePublication): - def __init__(self, binding, owner, generation, presentation) -> None: + def __init__(self, binding: BoundView, owner: TranscriptPresentation, + generation: int, presentation: ThreadPresentation) -> None: super().__init__(binding, owner, generation) self.presentation = presentation self.agent_binding = binding.view.agent_binding @@ -272,11 +275,13 @@ def __init__(self, owner: TranscriptPresentation) -> None: self.worker: Worker[None] | None = None self.observation = ResourceRelease() - def request(self, kind: type[TranscriptPublication], *args) -> None: - publication = self.owner.binding.capture(self.owner, kind, *args) - if publication is None: + def request( + self, kind: Callable[[BoundView, TranscriptPresentation, int], TranscriptPublication] + ) -> None: + frame = self.owner.binding.capture(self.owner, BoundFrame) + if frame is None: return - self.submit(publication) + self.submit(kind(frame.binding, frame.owner, frame.generation)) def submit(self, publication: TranscriptPublication) -> None: """Keep the original operation when its caller is the native UI pump.""" @@ -408,7 +413,7 @@ def publication_current(): class ViewBinding: """The native view's binding and its view-dependent operations.""" - def matches(self, view) -> TypeGuard[Conversation]: + def matches(self, view: ConversationSessionBinding) -> TypeGuard[Conversation]: return False def bound_to(self, view: Conversation): @@ -506,10 +511,10 @@ def __init__(self, view: Conversation) -> None: self.view, self.window, self.contents = view, view.window, view.contents self._history, = self.contents.query_children(TranscriptHistory) - def matches(self, view) -> TypeGuard[Conversation]: + def matches(self, view: ConversationSessionBinding) -> TypeGuard[Conversation]: return self.view is view - def bound_to(self, view): + def bound_to(self, view: Conversation): if not self.matches(view): raise ValueError("Transcript already has a native publisher") return self diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 0a47dee66..699478905 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -3,7 +3,7 @@ import asyncio from abc import abstractmethod from contextlib import suppress -from functools import cached_property +from functools import cached_property, partial from itertools import filterfalse from operator import attrgetter from pathlib import Path @@ -403,7 +403,9 @@ def thread_presented(self, presentation) -> None: if self.query_ancestor(SessionView).is_current: from toad.transcript_publication import ObservedSourcePublication self._unpublished_presentation = None - self.transcript.source_requests.request(ObservedSourcePublication, presentation) + self.transcript.source_requests.request( + partial(ObservedSourcePublication, presentation=presentation) + ) else: self._unpublished_presentation = presentation @@ -419,7 +421,9 @@ async def _thread_view_selected(self, event: CoreEventMessage) -> None: if presentation is not None: from toad.transcript_publication import ObservedSourcePublication self._unpublished_presentation = None - self.transcript.source_requests.request(ObservedSourcePublication, presentation) + self.transcript.source_requests.request( + partial(ObservedSourcePublication, presentation=presentation) + ) @handles(core_events.ObservedServiceChanged) async def _thread_route_observed(self, event: CoreEventMessage) -> None: From 328d031a7dfe02949d43ae09abaa668d9e898e93 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 07:12:52 -0400 Subject: [PATCH 3/4] Build source publications in one capture call again request() built a throwaway BoundFrame and copied its three fields into a callable typed with those same fields (MEMB-5). The binding's capture already builds the publication from its kind; request types the kind and its one extra argument and hands both to capture. Co-Authored-By: Claude Opus 5.5 --- src/toad/transcript_publication.py | 11 ++++------- src/toad/widgets/conversation.py | 10 +++------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index fc0d9df6a..dcca90241 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -4,7 +4,6 @@ import asyncio from abc import ABC, abstractmethod from collections import deque -from collections.abc import Callable from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from functools import partial @@ -275,13 +274,11 @@ def __init__(self, owner: TranscriptPresentation) -> None: self.worker: Worker[None] | None = None self.observation = ResourceRelease() - def request( - self, kind: Callable[[BoundView, TranscriptPresentation, int], TranscriptPublication] - ) -> None: - frame = self.owner.binding.capture(self.owner, BoundFrame) - if frame is None: + def request(self, kind: type[TranscriptPublication], *args: ThreadPresentation) -> None: + publication = self.owner.binding.capture(self.owner, kind, *args) + if publication is None: return - self.submit(kind(frame.binding, frame.owner, frame.generation)) + self.submit(publication) def submit(self, publication: TranscriptPublication) -> None: """Keep the original operation when its caller is the native UI pump.""" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 699478905..0a47dee66 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -3,7 +3,7 @@ import asyncio from abc import abstractmethod from contextlib import suppress -from functools import cached_property, partial +from functools import cached_property from itertools import filterfalse from operator import attrgetter from pathlib import Path @@ -403,9 +403,7 @@ def thread_presented(self, presentation) -> None: if self.query_ancestor(SessionView).is_current: from toad.transcript_publication import ObservedSourcePublication self._unpublished_presentation = None - self.transcript.source_requests.request( - partial(ObservedSourcePublication, presentation=presentation) - ) + self.transcript.source_requests.request(ObservedSourcePublication, presentation) else: self._unpublished_presentation = presentation @@ -421,9 +419,7 @@ async def _thread_view_selected(self, event: CoreEventMessage) -> None: if presentation is not None: from toad.transcript_publication import ObservedSourcePublication self._unpublished_presentation = None - self.transcript.source_requests.request( - partial(ObservedSourcePublication, presentation=presentation) - ) + self.transcript.source_requests.request(ObservedSourcePublication, presentation) @handles(core_events.ObservedServiceChanged) async def _thread_route_observed(self, event: CoreEventMessage) -> None: From 260806fdc0486467908be02d6067c760e5c1b93a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 07:15:07 -0400 Subject: [PATCH 4/4] Pass show_draft the draft and identity it reads show_draft took a union of two update classes to read two fields both declare; it now takes those values, and no union of our own classes stands in for a parent neither shares. Co-Authored-By: Claude Opus 5.5 --- src/toad/transcript_publication.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 5a0495bcf..aeef69dbe 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -10,10 +10,11 @@ from functools import partial from typing import TYPE_CHECKING, TypeGuard -from agent_comms.transcripts import TranscriptCursor, TranscriptPage +from agent_comms.transcripts import TranscriptCursor, TranscriptPage, TranscriptReadIdentity from agent_comms.thread_presentation import ThreadPresentation from agent_comms.transcript_events import TranscriptCoverage from agent_comms.acp_extension import InputStartedUpdate, TranscriptDraftUpdate, TranscriptSnapshotUpdate +from agent_comms.external_transcript import DraftRow from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.ui_model.observation import ( NotPublished, @@ -226,12 +227,11 @@ async def publish_applied(self) -> bool: @handles(TranscriptDraftUpdate) async def publish_draft(self, snapshot: TranscriptDraftUpdate) -> bool: if self.current(): - self.show_draft(snapshot) + self.show_draft(snapshot.draft, snapshot.identity) self.owner.current_read = Published(snapshot.identity) return True - def show_draft(self, snapshot: TranscriptDraftUpdate | TranscriptSnapshotUpdate) -> None: - draft = snapshot.draft + def show_draft(self, draft: DraftRow | None, identity: TranscriptReadIdentity) -> None: widget = self.binding.view.cli_draft if self.binding.contents.children[-1] is not widget: self.binding.contents.move_child(widget, after=-1) @@ -240,7 +240,7 @@ def show_draft(self, snapshot: TranscriptDraftUpdate | TranscriptSnapshotUpdate) else: widget.show( draft, - resolve_sent=snapshot.identity.wire_frontier.resolve_sent_messages, + resolve_sent=identity.wire_frontier.resolve_sent_messages, ) @handles(TranscriptSnapshotUpdate) @@ -262,7 +262,7 @@ async def publish_snapshot(self, snapshot: TranscriptSnapshotUpdate) -> bool: return True published = await self.publish_source_page(page) if self.current(): - self.show_draft(snapshot) + self.show_draft(snapshot.draft, snapshot.identity) if published and bound == snapshot.page.after: self.owner.current_read = Published(snapshot.identity) if published and self.current() and self.destination is not None: