diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 19af00e5c..f175b578d 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 AgentConnection(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/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 12a07a34d..aeef69dbe 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -10,9 +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, @@ -26,6 +28,7 @@ ) from textual.worker import Worker, WorkerCancelled from textual.await_complete import AwaitComplete +from textual.widget import Widget from toad.widgets.history_anchor import ( HeldReaderDestination, TailPosition, @@ -54,7 +57,7 @@ ) if TYPE_CHECKING: - from toad.widgets.conversation import Conversation + from toad.widgets.conversation import Conversation, ConversationSessionBinding @dataclass(frozen=True) @@ -64,7 +67,7 @@ class BoundFrame: 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): @@ -74,17 +77,17 @@ def current(self): 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() @@ -224,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) -> 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) @@ -238,7 +240,7 @@ def show_draft(self, snapshot) -> None: else: widget.show( draft, - resolve_sent=snapshot.identity.wire_frontier.resolve_sent_messages, + resolve_sent=identity.wire_frontier.resolve_sent_messages, ) @handles(TranscriptSnapshotUpdate) @@ -260,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: @@ -273,7 +275,8 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: class ObservedSourcePublication(CanonicalSourcePublication): - def __init__(self, binding, owner, generation, presentation): + 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 @@ -289,13 +292,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: ThreadPresentation) -> None: publication = self.owner.binding.capture(self.owner, kind, *args) if publication is None: return @@ -346,7 +349,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) @@ -448,10 +451,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: ConversationSessionBinding) -> TypeGuard[Conversation]: return False - def bound_to(self, view): + def bound_to(self, view: Conversation): return view.transcript_binding() @property @@ -460,13 +463,13 @@ def thread_scope(self) -> TranscriptScope: 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): @@ -476,19 +479,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): @@ -497,13 +500,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 @@ -517,7 +520,7 @@ class Moment(BoundFrame): source: SessionScope @classmethod - def capture(cls, binding, owner, **extra): + def capture(cls, binding: BoundView, owner, **extra): return super().capture( binding, owner, @@ -543,7 +546,7 @@ def current(self): 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: @@ -554,14 +557,14 @@ 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: 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 @@ -587,7 +590,7 @@ def current(self): def history(self) -> TranscriptHistory: return self._history - def observe_thread(self, owner): + def observe_thread(self, owner) -> None: view = self.view owner._thread_observation.enter_context( self.thread_scope.observe( @@ -605,7 +608,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): @@ -613,7 +616,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 @@ -628,7 +631,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) @@ -639,7 +642,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: @@ -658,7 +661,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 @@ -671,11 +674,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)) @@ -732,14 +735,14 @@ def covered(self, owner, coverage, history): [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() @@ -940,7 +943,7 @@ async def suspend(self) -> None: except WorkerCancelled: 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 dd7bab49e..0e3936437 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -5,16 +5,18 @@ 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, @@ -24,11 +26,11 @@ ) 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.session_scope import SessionScope +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 @@ -43,7 +45,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 @@ -53,11 +55,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) @@ -71,7 +73,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 = {} @@ -87,10 +89,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, @@ -113,7 +115,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}") @@ -124,7 +126,7 @@ def search_results(self, matches, description, query): "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) @@ -148,14 +150,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) @@ -174,7 +176,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, @@ -194,7 +196,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. @@ -203,7 +205,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): @@ -211,13 +213,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) @@ -243,9 +245,9 @@ class ContextExplorer(ShowsThreadIdentity, SidebarVisibilityObserver, Vertical): """ 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", @@ -286,13 +288,13 @@ def compose(self): ) 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): @@ -303,13 +305,13 @@ def presentation_visible(self): 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. @@ -320,7 +322,7 @@ 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" @@ -328,7 +330,7 @@ def context_read_finished(self, event): ): self._observed() - def on_unmount(self): + def on_unmount(self) -> None: self.state = DetachedInspection() def identity_changed(self, identity: SessionIdentity) -> None: @@ -342,7 +344,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) @@ -353,20 +355,20 @@ def _reading(self, identity: SessionIdentity): 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 @@ -392,7 +394,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() ) @@ -426,19 +428,20 @@ async def _read_native(self, captured: HoldingInspection): 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() @@ -462,7 +465,7 @@ def action_correct(self, event): ) @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(SessionScope).project_root @@ -472,7 +475,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 @@ -492,24 +495,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. @@ -525,18 +528,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) @@ -552,7 +555,7 @@ async def _search(self, captured: HoldingInspection, query, selected): 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) @@ -561,7 +564,7 @@ def _searching(self, captured, 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 @@ -578,7 +581,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 @@ -589,7 +592,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 @@ -603,7 +606,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: @@ -617,7 +620,7 @@ def _selected(self, model): ) @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 221d81727..ef0312e45 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -4,43 +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, + SessionSelected, 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_event_carrier import CoreEventMessage, CoreEventReceiver from toad.widgets.side_bar import SideBar +from toad.widgets.sidebar_viewport import SidebarHeader class UnpaintedUnread(UnreadPresentation): @@ -50,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 ) @@ -94,7 +95,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 @@ -103,7 +104,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 = {} @@ -134,18 +135,18 @@ def synchronized(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 @@ -252,7 +253,7 @@ def __init__( controls=(), disclosure_type=SidebarDisclosure, **kwargs, - ): + ) -> None: super().__init__(**kwargs) self.row = row self.rows: dict[str, RowT] = {} @@ -508,7 +509,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): @@ -527,16 +528,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 @@ -622,7 +623,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) @@ -785,7 +786,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: @@ -818,7 +819,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 @@ -922,7 +923,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)