diff --git a/pyproject.toml b/pyproject.toml index 6e1015e9f..41a978179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "5e58d8b5907cec4dbc8810a9329aaf902f69f675" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "46ee4a206a28dfb97ff93ed4d839436cda4b5261" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "b6b79ac0352eb8677e024a90f64a56d44af0d182" } textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "18f4e7a67403d56846ef5abf55c93e3e0639be33" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } diff --git a/src/toad/acp/attachment_presentation.py b/src/toad/acp/attachment_presentation.py index f9a76c8c1..353f8799d 100644 --- a/src/toad/acp/attachment_presentation.py +++ b/src/toad/acp/attachment_presentation.py @@ -9,7 +9,7 @@ @dataclass(frozen=True) class CursorPresentation: - status: type[CursorStatus] | None + status: type[CursorStatus] @dataclass(frozen=True) diff --git a/src/toad/acp/projection_attachment.py b/src/toad/acp/projection_attachment.py index 3d216c71d..ae8e09793 100644 --- a/src/toad/acp/projection_attachment.py +++ b/src/toad/acp/projection_attachment.py @@ -266,7 +266,7 @@ class CursorAttachment(ProjectionAttachment): """Cursor attachment lifecycle projected as named presentation states.""" @property - def status(self) -> type[CursorStatus] | None: + def status(self) -> type[CursorStatus]: return self.state.project( self.current, available=lambda current: CursorStatus.external(current.status), diff --git a/src/toad/agent_presentation.py b/src/toad/agent_presentation.py index 18444b853..55d878b82 100644 --- a/src/toad/agent_presentation.py +++ b/src/toad/agent_presentation.py @@ -4,13 +4,13 @@ from pathlib import Path from agent_comms.declared_family import DeclaredFamily from agent_comms.acp_extension import PendingQueueProjection, QueueProjection -from toad.private_native_cursor import CursorStatus +from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.conversation_turn import TurnBinding, LocalTurnBinding, ManagedTurnBinding @dataclass(frozen=True) class AgentAttachmentView: - cursor: type[CursorStatus] | None + cursor: type[CursorStatus] cursor_sequence: int @@ -61,7 +61,7 @@ def queue(self): @property def attachments(self): - return AgentAttachmentView(None, 0) + return AgentAttachmentView(HiddenCursorStatus, 0) class ACPAgentPresentation(AgentPresentation): diff --git a/src/toad/conversation_turn.py b/src/toad/conversation_turn.py index c09692126..09e682cb2 100644 --- a/src/toad/conversation_turn.py +++ b/src/toad/conversation_turn.py @@ -5,6 +5,7 @@ from agent_comms.activity import ActivityState from agent_comms.declared_family import DeclaredFamily from agent_comms.turn_lease import TurnState +from agent_comms.ui_model.status import ThreadStatusModel class TurnOwner(DeclaredFamily, affix="Turn"): @@ -164,6 +165,9 @@ class TurnBinding(DeclaredFamily, affix="TurnBinding"): @abstractmethod def owner(self) -> TurnOwner: ... + @abstractmethod + def status_row(self, observed: ThreadStatusModel) -> ThreadStatusModel: ... + @abstractmethod def accepts(self, message) -> bool: ... @@ -191,6 +195,10 @@ def __init__(self, agent=None): def owner(self): return self._owner + def status_row(self, observed): + owner = self.owner + return ThreadStatusModel.known("", owner.activity_state.presentation("", owner.activity)) + def accepts(self, message): """A local turn cannot accept a backend-owned managed publication.""" return False @@ -224,6 +232,9 @@ def __init__(self, agent): def owner(self): return self._owner + def status_row(self, observed): + return observed + def accepts(self, message): return (message.publisher is self.agent and message.event.session_id == self.agent.session_id and message.event.sequence == self.sequence) diff --git a/src/toad/private_native_cursor.py b/src/toad/private_native_cursor.py index 84723b898..9e54a6ec4 100644 --- a/src/toad/private_native_cursor.py +++ b/src/toad/private_native_cursor.py @@ -9,6 +9,7 @@ from typing import ClassVar from agent_comms.declared_family import DeclaredFamily +from agent_comms.thread_presentation import ThreadPresentationStatus class CursorStatus(DeclaredFamily, affix="CursorStatus"): @@ -16,14 +17,25 @@ class CursorStatus(DeclaredFamily, affix="CursorStatus"): label: ClassVar[str] summary: ClassVar[str] = "" - attention: ClassVar[bool] = False + visible: ClassVar[bool] = True @classmethod - def external(cls, value: str | None) -> type[CursorStatus] | None: + def external(cls, value: str | None) -> type[CursorStatus]: if value is None: - return None + return HiddenCursorStatus return cls.decode(value) + @classmethod + def present_status(cls, status: ThreadPresentationStatus) -> ThreadPresentationStatus: + return status + + +class HiddenCursorStatus(CursorStatus): + """This session does not expose native cursor verification.""" + + label = "" + visible = False + class PendingCursorStatus(CursorStatus): label = "Checking bus input verification…" @@ -45,7 +57,10 @@ class NoneCursorStatus(CursorStatus): class UnavailableCursorStatus(CursorStatus): label = "Bus input verification unavailable — check the ACP log; saved history is separate" summary = "Bus input verification unavailable" - attention = True + + @classmethod + def present_status(cls, status: ThreadPresentationStatus) -> ThreadPresentationStatus: + return status.with_attention() TOOLTIP = ( diff --git a/src/toad/session_observation.py b/src/toad/session_observation.py index 937a25e84..1cf0d0f94 100644 --- a/src/toad/session_observation.py +++ b/src/toad/session_observation.py @@ -122,10 +122,9 @@ async def read(self, agent) -> InputDelivery: def publish(self, view, result: InputDelivery): view.input_delivery = result - view.input_delivery_error = "" def failed(self, view, error): - view.input_delivery_error = f"Delivery unavailable: {error}" + view.input_delivery = view.input_delivery.failed(error) def require_owner(self, agent): if self.view is None or not self.view.is_attached or agent is not self.view.agent: @@ -149,8 +148,8 @@ async def history(self): continue await self.refresh() self.require_owner(agent) - if self.view.input_delivery_error: - raise ValueError(self.view.input_delivery_error) + if self.view.input_delivery.error: + raise ValueError(self.view.input_delivery.error) if revision + 1 != self.revision or result.history_changed(self.view.input_delivery): continue return result.historical_inputs @@ -161,5 +160,5 @@ async def dismiss_history(self): self.require_owner(agent) await self.refresh() self.require_owner(agent) - if self.view.input_delivery_error: - raise ValueError(self.view.input_delivery_error) + if self.view.input_delivery.error: + raise ValueError(self.view.input_delivery.error) diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 75e7477b8..0e0e2cabb 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -107,6 +107,12 @@ def __init__(self) -> None: self.shell = None self.load_admission = EnsuringSessionLoadAdmission() + def status_row(self, conversation: Conversation): + return self.activity_row(conversation).with_delivery(conversation.input_delivery) + + def activity_row(self, conversation: Conversation): + return conversation.turns.binding.status_row(conversation.thread_activity.row) + def agent_info(self, conversation: Conversation) -> str: return "shell" @@ -217,6 +223,9 @@ async def close(self) -> None: class MailboxSessionSources(OperationalSessionSources): """CLI execution receives user messages through Core's inbox, not ACP.""" + def activity_row(self, conversation: Conversation): + return conversation.thread_activity.row + def agent_info(self, conversation: Conversation) -> str: scope = conversation.transcript.thread_scope return f"@{scope[1]} · CLI inbox" if scope is not None else "CLI inbox" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 88a0f813f..89724ab3a 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -84,7 +84,7 @@ from toad.widgets.cli_draft import CliDraft from toad.widgets.transcript_history import JumpToLatest from toad.widgets.session_details import SessionDetails -from toad.private_native_cursor import CursorStatus +from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.block_navigation import require_blocks, ConversationBlock, ContentNavigation, UpCursor, DownCursor from functools import cached_property from toad.agent_presentation import AgentAttachmentView @@ -470,10 +470,9 @@ def mode_selection_available(self) -> bool: input_delivery: var[InputDelivery] = var(InputDelivery()) - input_delivery_error: var[str] = var("") - native_history_status: var[type[CursorStatus] | None] = var(None) + native_history_status: var[type[CursorStatus]] = var(lambda: HiddenCursorStatus) goal_display: var[GoalDisplay] = var(NoGoal()) @@ -736,7 +735,7 @@ def watch_agent(self, agent: AgentBase | None) -> None: if agent is not None: self.bind_agent(agent) attachments = (agent.presentation.attachments if agent is not None - else AgentAttachmentView(None, 0)) + else AgentAttachmentView(HiddenCursorStatus, 0)) self.native_history_status = attachments.cursor self._private_cursor_sequence = attachments.cursor_sequence self.submissions.reset() @@ -914,17 +913,18 @@ def compose(self) -> ComposeResult: yield self.cli_draft yield Flash() with containers.Vertical(id="prompt-stack"): + from functools import partial self.thread_activity = SessionDetails( - turns=self.turns, + status_source=partial(self.operational_sources.status_row, self), transcript=self.transcript, history=NativeHistory().data_bind( status=Conversation.native_history_status ), delivery=InputDeliveryBar().data_bind( delivery=Conversation.input_delivery, - error=Conversation.input_delivery_error, ), ) + self.refresh_thread_status() yield self.thread_activity prompt = self.operational_sources.prompt(self).data_bind( project_path=Conversation.project_path, @@ -1732,7 +1732,6 @@ async def inspect_session(self, event: DetailAction.Activated) -> None: ) # A modal remains a view of the same backend snapshot, including later starts. details.delivery = self.input_delivery - details.error = self.input_delivery_error session_details = self.query_one_optional(SessionDetails) if session_details is not None: details.overview_text = session_details.overview_text @@ -1740,9 +1739,6 @@ async def inspect_session(self, event: DetailAction.Activated) -> None: details.watch( self, "input_delivery", lambda state: setattr(details, "delivery", state) ) - details.watch( - self, "input_delivery_error", lambda error: setattr(details, "error", error) - ) if session_details is not None: details.watch( session_details, diff --git a/src/toad/widgets/input_delivery.py b/src/toad/widgets/input_delivery.py index 0eb3a7360..4c6ed03de 100644 --- a/src/toad/widgets/input_delivery.py +++ b/src/toad/widgets/input_delivery.py @@ -61,7 +61,6 @@ class InputDeliveryBar(HorizontalGroup): InputDeliveryBar #delivery-error { width: 1fr; height: auto; color: $warning; } """ delivery: var[InputDelivery] = var(InputDelivery()) - error: var[str] = var("") def compose(self) -> ComposeResult: yield Static(markup=False, id="delivery-summary") @@ -71,13 +70,10 @@ def compose(self) -> ComposeResult: def watch_delivery(self) -> None: self._refresh_summary() - def watch_error(self) -> None: - self._refresh_summary() - def _refresh_summary(self) -> None: delivery = self.delivery current, history = delivery.current, delivery.historical_count - self.display = delivery.shown or bool(self.error) + self.display = delivery.shown or bool(delivery.error) if self.is_attached: summary = self.query_one("#delivery-summary", Static) summary.display = bool(current) @@ -88,8 +84,8 @@ def _refresh_summary(self) -> None: count = f"{history} {label}" if history else f"{delivery.dismissed_historical_count} {label} cleared" historical.update(f"{' · ' if current else ''}{count}") error = self.query_one("#delivery-error", Static) - error.display = bool(self.error) - error.update(self.error) + error.display = bool(delivery.error) + error.update(delivery.error) def on_mount(self) -> None: self._refresh_summary() @@ -190,12 +186,13 @@ def watch_overview_text(self, value: str) -> None: def watch_error(self) -> None: if self.is_attached: error = self.query_one("#delivery-error", Static) - error.display = bool(self.error) - error.update(self.error) + error.display = bool(self.delivery.error or self.error) + error.update(self.delivery.error or self.error) def _refresh_records(self) -> None: if not self.is_attached: return + self.watch_error() self.query_one("#delivery-records", Static).update( self._records(self.delivery.inputs) or "No inputs currently awaiting start." ) diff --git a/src/toad/widgets/native_history.py b/src/toad/widgets/native_history.py index 551e38f90..6547a7b68 100644 --- a/src/toad/widgets/native_history.py +++ b/src/toad/widgets/native_history.py @@ -3,20 +3,20 @@ from textual.reactive import var from textual.widgets import Static -from toad.private_native_cursor import TOOLTIP, CursorStatus +from toad.private_native_cursor import TOOLTIP, CursorStatus, HiddenCursorStatus class NativeHistory(Static): DEFAULT_CSS = """ NativeHistory { height: auto; padding: 0 1; color: $text-muted; } """ - status: var[type[CursorStatus] | None] = var(None) + status: var[type[CursorStatus]] = var(lambda: HiddenCursorStatus) def __init__(self) -> None: super().__init__("", markup=False) self.tooltip = TOOLTIP self.display = False - def watch_status(self, status: type[CursorStatus] | None) -> None: - self.display = status is not None - self.update(status.label if status is not None else "") + def watch_status(self, status: type[CursorStatus]) -> None: + self.display = status.visible + self.update(status.label) diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index 1d6ba0ca0..7e4d582b3 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -8,6 +8,7 @@ from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.changes import Changes from agent_comms.ui_model.status import ThreadStatusModel +from agent_comms.ui_model.observation import UnreadableHistory from textual.message import Message from textual.reactive import var from textual.widget import Widget @@ -19,7 +20,7 @@ class ObservedThreadActivity(Widget): Observation never starts/settles an ACP turn or changes send admission. """ - row: var[ThreadStatusModel | None] = var(None, init=False) + row: var[ThreadStatusModel] = var(lambda: ThreadStatusModel.pending(""), init=False) class Changed(Message): """The observed row changed; the view repaints what derives from it.""" @@ -39,18 +40,18 @@ def bind(self, thread: str | None) -> None: if thread != self.thread: self.thread = thread if thread is not None: - self.show(self.app.coordination_access.thread_status.rows.rows.get(thread)) + self.show(self.app.coordination_access.thread_status.open(thread)) def rows_changed(self, changes: Changes[str]) -> None: thread = self.thread if thread is not None and (thread in changes.changed or thread in changes.added or thread in changes.removed): - self.show(self.app.coordination_access.thread_status.rows.rows.get(thread)) + self.show(self.app.coordination_access.thread_status.open(thread)) - def show(self, row: ThreadStatusModel | None) -> None: + def show(self, row: ThreadStatusModel) -> None: self.row = row - def watch_row(self, row: ThreadStatusModel | None) -> None: + def watch_row(self, row: ThreadStatusModel) -> None: self.activity_changed() def activity_changed(self) -> None: @@ -58,7 +59,7 @@ def activity_changed(self) -> None: @property def working(self) -> bool: - return self.row is not None and self.row.status.busy + return self.row.status.busy class ThreadStatusOwner: @@ -115,8 +116,10 @@ def refresh_thread_status(self) -> None: if activity is not None: activity.bind(None) if thread is None: - activity.show(None) + activity.show(ThreadStatusModel.pending("")) if thread is not None: + if activity is not None: + activity.show(ThreadStatusModel.pending(thread[1])) self.run_worker(self._read_foreign_status(thread[1]), group="foreign-thread-status", exclusive=True) @@ -126,9 +129,9 @@ async def _read_foreign_status(self, name: str) -> None: row = ThreadStatusModel.known(name, presentation) except CoordinationReadUnavailable: return # A busy store; the next coordination change reads again. - except (OSError, ValueError, RuntimeError): - # Core read families present as "Agent status unavailable". - presentation, row = None, ThreadStatusModel.unavailable(name) + except (OSError, ValueError, RuntimeError) as error: + presentation = None + row = ThreadStatusModel.unreadable(name, UnreadableHistory.capture(error)) if (self.status_thread() or (None, None))[1] != name: return if (activity := self.thread_activity) is not None: diff --git a/src/toad/widgets/session_details.py b/src/toad/widgets/session_details.py index fc2fda009..88a9fc42f 100644 --- a/src/toad/widgets/session_details.py +++ b/src/toad/widgets/session_details.py @@ -3,7 +3,15 @@ from __future__ import annotations from typing import TYPE_CHECKING - +from collections.abc import Callable + +from agent_comms.mro_dispatch import handles +from agent_comms.thread_presentation import ( + AttentionThreadPresentationStatus, IdleThreadPresentationStatus, + WorkingThreadPresentationStatus, StatusPending, ThreadRetired, +) +from agent_comms.ui_model.status import ThreadStatusModel +from toad.core.projection import MroProjection from textual.content import Content from textual.reactive import var from textual.widgets import Collapsible, Static @@ -13,14 +21,13 @@ from toad.widgets.native_history import NativeHistory from toad.widgets.observed_thread_activity import ObservedThreadActivity from toad.widgets.session_history_details import SessionHistoryDetails -from toad.conversation_turn import ConversationTurn if TYPE_CHECKING: from toad.transcript_publication import TranscriptPresentation -class SessionDetails(Collapsible, ObservedThreadActivity): +class SessionDetails(Collapsible, ObservedThreadActivity, MroProjection): """The observed-thread capability and disclosure share one status view. Core owns status for an observed thread, native or CLI. A standalone ACP @@ -44,11 +51,11 @@ class SessionDetails(Collapsible, ObservedThreadActivity): def __init__( self, *, - history: NativeHistory | None = None, delivery: InputDeliveryBar | None = None, + history: NativeHistory, delivery: InputDeliveryBar | None = None, transcript: TranscriptPresentation | None = None, - turns: ConversationTurn | None = None, + status_source: Callable[[], ThreadStatusModel], ) -> None: - self.turns = turns + self.status_source = status_source self.history = history self.history_details = SessionHistoryDetails(transcript, history) self.delivery = delivery @@ -59,35 +66,38 @@ def __init__( title="", collapsed=True, id="session-details", ) + @handles(IdleThreadPresentationStatus) + def idle_role(self, status) -> type[LineRole]: + return MutedRole + + @handles(WorkingThreadPresentationStatus) + def working_role(self, status) -> type[LineRole]: + return WorkingRole + + @handles(AttentionThreadPresentationStatus) + def attention_role(self, status) -> type[LineRole]: + return NoticeRole + + @handles(StatusPending) + def pending_role(self, status) -> type[LineRole]: + return MutedRole + + @handles(ThreadRetired) + def retired_role(self, status) -> type[LineRole]: + return NoticeRole + @property def title_role(self) -> type[LineRole]: - """Derive existing theme paint from the original status sources.""" - if self.history_details.attention: - return NoticeRole - if self.delivery is not None and (self.delivery.delivery.current or self.delivery.error): - return NoticeRole - row = self.row - if row is not None and row.shown: - status = row.status - elif self.turns is not None: - status = self.turns.owner.presentation_status - else: - return MutedRole - # Core owns the state; this is its one frontend paint decision. - if status.attention: - return NoticeRole - if status.busy: - return WorkingRole - return MutedRole + return self.dispatch_sync( + self.history.status.present_status(self.status_source().status) + ) def on_mount(self) -> None: super().on_mount() self.query_children(self.Contents).first().styles.max_height = self.DETAIL_ROWS - if self.history is not None: - self.watch(self.history, "status", self._refresh_summary) + self.watch(self.history, "status", self._refresh_summary) if self.delivery is not None: self.watch(self.delivery, "delivery", self._refresh_summary) - self.watch(self.delivery, "error", self._refresh_summary) self._refresh_summary() def activity_changed(self) -> None: @@ -100,22 +110,17 @@ def notify_style_update(self) -> None: self._refresh_summary() def _refresh_summary(self, *_args) -> None: - row = self.row - shown = row is not None and row.shown - owner = self.turns.owner if self.turns is not None else None - if shown: - title = row.summary[0] - else: - title = owner.status if owner is not None else "" + row = self.status_source() + title = row.summary[0] # The status is already the disclosure's title, not a second row inside. - detail = "\n".join(row.lines[1:]) if shown else "" + detail = "\n".join(row.lines[1:]) self.notifications.update(detail) self.notifications.display = bool(detail) label = Content.styled(title, self.app.colours.style(self.title_role)) # Native Content compares text and spans; no last-title/status mirror. if not Content.from_text(self.title, markup=False).is_same(label): self.title = label - self.overview_text = self.history_details.overview(row.lines if shown else ()) + self.overview_text = self.history_details.overview(row.lines) self.display = bool(title) def sync_turn(self) -> None: diff --git a/src/toad/widgets/session_history_details.py b/src/toad/widgets/session_history_details.py index 13bbee71c..2ffb56e5b 100644 --- a/src/toad/widgets/session_history_details.py +++ b/src/toad/widgets/session_history_details.py @@ -9,7 +9,7 @@ class SessionHistoryDetails: - def __init__(self, transcript: TranscriptPresentation | None, native: NativeHistory | None): + def __init__(self, transcript: TranscriptPresentation | None, native: NativeHistory): self.transcript = transcript self.native = native @@ -19,20 +19,15 @@ def summary(self): if self.transcript is not None: parts.append("Saved history available" if self.transcript.reports_coverage else "Saved history not loaded") - status = self.native.status if self.native is not None else None - if status is not None and status.summary: + status = self.native.status + if status.summary: parts.append(status.summary) return parts - @property - def attention(self): - status = self.native.status if self.native is not None else None - return status is not None and status.attention - def overview(self, activity_lines: tuple[str, ...]): return "\n\n".join([ *self.summary, *activity_lines, *(str(item.render()) for item in (self.native,) - if item is not None and item.display), + if item.display), ]) diff --git a/uv.lock b/uv.lock index e141641a9..c7b369fe4 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=46ee4a206a28dfb97ff93ed4d839436cda4b5261#46ee4a206a28dfb97ff93ed4d839436cda4b5261" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=b6b79ac0352eb8677e024a90f64a56d44af0d182#b6b79ac0352eb8677e024a90f64a56d44af0d182" } dependencies = [ { name = "aiohttp" }, { name = "metaclass-registry" }, @@ -306,7 +306,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", extras = ["acp"], git = "https://github.com/OpenHCSDev/agent-comms.git?rev=46ee4a206a28dfb97ff93ed4d839436cda4b5261" }, + { name = "agent-comms", extras = ["acp"], git = "https://github.com/OpenHCSDev/agent-comms.git?rev=b6b79ac0352eb8677e024a90f64a56d44af0d182" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" },