From c37bad0ff3afb136d92e09f909c3ae5b350d96ac Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 23:23:17 -0400 Subject: [PATCH 1/4] Consume Core presentation members without re-deciding busy and attention --- src/toad/conversation_turn.py | 20 ++++++++---- src/toad/session_tracker.py | 2 +- src/toad/widgets/channel_participants.py | 2 +- src/toad/widgets/observed_thread_activity.py | 2 +- src/toad/widgets/session_details.py | 32 ++++++++++++++------ src/toad/widgets/session_sidebar.py | 2 +- 6 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/toad/conversation_turn.py b/src/toad/conversation_turn.py index 6372969aa..c09692126 100644 --- a/src/toad/conversation_turn.py +++ b/src/toad/conversation_turn.py @@ -2,6 +2,7 @@ from abc import abstractmethod from dataclasses import dataclass, replace +from agent_comms.activity import ActivityState from agent_comms.declared_family import DeclaredFamily from agent_comms.turn_lease import TurnState @@ -10,10 +11,15 @@ class TurnOwner(DeclaredFamily, affix="Turn"): managed_id = None activity = "" started_at = None + activity_state = ActivityState.IDLE @property - @abstractmethod - def busy(self) -> bool: ... + def presentation_status(self): + return self.activity_state.presentation_status + + @property + def busy(self) -> bool: + return self.activity_state.busy @property def status(self) -> str: @@ -57,20 +63,18 @@ def response_stream(self, delivery): @dataclass(frozen=True) class NoTurn(TurnOwner): - busy = False session_state = None @dataclass(frozen=True) class ClientTurn(TurnOwner): - busy = False session_state = "idle" @dataclass(frozen=True) class ActivityTurn(TurnOwner): activity: str - busy = True + activity_state = ActivityState.WORKING session_state = "busy" def with_activity(self, activity: str) -> TurnOwner: @@ -83,7 +87,7 @@ class AgentTurn(TurnOwner): managed_id: str | None = None activity: str = "Thinking…" started_at: float | None = None - busy = True + activity_state = ActivityState.WORKING session_state = "busy" def matches_settlement(self, turn_id) -> bool: @@ -106,6 +110,10 @@ class ManagedTurn(TurnOwner): def busy(self): return self.state.busy + @property + def activity_state(self): + return self.state.phase.activity_state + @property def managed_id(self): return self.state.managed_id diff --git a/src/toad/session_tracker.py b/src/toad/session_tracker.py index b70b5b615..f88554663 100644 --- a/src/toad/session_tracker.py +++ b/src/toad/session_tracker.py @@ -58,7 +58,7 @@ def unread(self) -> UnreadPresentation: @property def busy(self) -> bool: - return self.row.busy + return self.row.status.busy @dataclass(frozen=True) diff --git a/src/toad/widgets/channel_participants.py b/src/toad/widgets/channel_participants.py index 50ba9b144..4c10f42de 100644 --- a/src/toad/widgets/channel_participants.py +++ b/src/toad/widgets/channel_participants.py @@ -37,7 +37,7 @@ def prepare_participants(people: tuple[ThreadRowModel, ...]) -> tuple[Content, C for presentation in people: summaries.append(f"{presentation.name}: {presentation.summary}") names.append(Content.styled( - presentation.label, "$warning" if presentation.busy else "$text-muted", + presentation.label, "$warning" if presentation.status.busy else "$text-muted", ).stylize(Style.from_meta({"@click": OpenTarget(presentation.name).click}))) content = Content.assemble("Active: ", Content(" · ").join(names)) if names else Content("No active turns") return content, Content("\n".join(summaries)) diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index 8129fd571..1d6ba0ca0 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -58,7 +58,7 @@ def activity_changed(self) -> None: @property def working(self) -> bool: - return self.row is not None and self.row.working + return self.row is not None and self.row.status.busy class ThreadStatusOwner: diff --git a/src/toad/widgets/session_details.py b/src/toad/widgets/session_details.py index 11e9ae891..68d699d9a 100644 --- a/src/toad/widgets/session_details.py +++ b/src/toad/widgets/session_details.py @@ -4,10 +4,15 @@ from typing import TYPE_CHECKING, ClassVar +from agent_comms.mro_dispatch import handles +from agent_comms.thread_presentation import ( + AttentionPresentationStatus, IdlePresentationStatus, WorkingPresentationStatus, +) from textual.content import Content from textual.reactive import var from textual.widgets import Collapsible, Static +from toad.core.projection import MroProjection from toad.line_blocks import LineRole, MutedRole, NoticeRole, WorkingRole from toad.widgets.input_delivery import DetailAction, InputDeliveryBar from toad.widgets.native_history import NativeHistory @@ -20,11 +25,23 @@ from toad.transcript_publication import TranscriptPresentation -class SessionStatus: - """The disclosure's paint meaning, not another backend execution state.""" +class SessionStatus(MroProjection): + """Toad paint for Core's original status member, without classifying it again.""" role: ClassVar[type[LineRole]] + @handles(IdlePresentationStatus) + def idle_status(self, status): + return IdleSessionStatus + + @handles(WorkingPresentationStatus) + def working_status(self, status): + return WorkingSessionStatus + + @handles(AttentionPresentationStatus) + def attention_status(self, status): + return AttentionSessionStatus + class IdleSessionStatus(SessionStatus): role = MutedRole @@ -38,7 +55,7 @@ class AttentionSessionStatus(SessionStatus): role = NoticeRole -class SessionDetails(Collapsible, ObservedThreadActivity): +class SessionDetails(Collapsible, ObservedThreadActivity, SessionStatus): """The observed-thread capability and disclosure share one status view. Core owns status for an observed thread, native or CLI. A standalone ACP @@ -86,12 +103,9 @@ def title_status(self) -> type[SessionStatus]: return AttentionSessionStatus row = self.row if row is not None and row.shown: - if row.attention: - return AttentionSessionStatus - if row.working: - return WorkingSessionStatus - elif self.turns is not None and self.turns.owner.busy: - return WorkingSessionStatus + return self.dispatch_sync(row.status) + if self.turns is not None: + return self.dispatch_sync(self.turns.owner.presentation_status) return IdleSessionStatus def on_mount(self) -> None: diff --git a/src/toad/widgets/session_sidebar.py b/src/toad/widgets/session_sidebar.py index cdfe51787..5d73c7c93 100644 --- a/src/toad/widgets/session_sidebar.py +++ b/src/toad/widgets/session_sidebar.py @@ -35,7 +35,7 @@ def __init__(self, *args, **kwargs): @property def busy(self) -> bool: - return self.thread_row is not None and (self.action_status is not None or self.thread_row.busy) + return self.thread_row is not None and (self.action_status is not None or self.thread_row.status.busy) def show(self, row: ThreadRowModel, *, pinned: bool = False, action_status: str | None = None) -> None: """Render the model's row with this view's own decoration: pin and pending action.""" From 4e668b9051d18475cc202ea3824fb2d715528bc4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 23:31:23 -0400 Subject: [PATCH 2/4] Key the stateless Toad role projection by Core status members --- src/toad/widgets/session_details.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/toad/widgets/session_details.py b/src/toad/widgets/session_details.py index 68d699d9a..c5a616291 100644 --- a/src/toad/widgets/session_details.py +++ b/src/toad/widgets/session_details.py @@ -6,7 +6,8 @@ from agent_comms.mro_dispatch import handles from agent_comms.thread_presentation import ( - AttentionPresentationStatus, IdlePresentationStatus, WorkingPresentationStatus, + AttentionThreadPresentationStatus, IdleThreadPresentationStatus, + WorkingThreadPresentationStatus, ) from textual.content import Content from textual.reactive import var @@ -30,15 +31,15 @@ class SessionStatus(MroProjection): role: ClassVar[type[LineRole]] - @handles(IdlePresentationStatus) + @handles(IdleThreadPresentationStatus) def idle_status(self, status): return IdleSessionStatus - @handles(WorkingPresentationStatus) + @handles(WorkingThreadPresentationStatus) def working_status(self, status): return WorkingSessionStatus - @handles(AttentionPresentationStatus) + @handles(AttentionThreadPresentationStatus) def attention_status(self, status): return AttentionSessionStatus @@ -55,7 +56,7 @@ class AttentionSessionStatus(SessionStatus): role = NoticeRole -class SessionDetails(Collapsible, ObservedThreadActivity, SessionStatus): +class SessionDetails(Collapsible, ObservedThreadActivity): """The observed-thread capability and disclosure share one status view. Core owns status for an observed thread, native or CLI. A standalone ACP @@ -64,6 +65,7 @@ class SessionDetails(Collapsible, ObservedThreadActivity, SessionStatus): """ DETAIL_ROWS = 8 + status_paint = SessionStatus() overview_text: var[str] = var("") DEFAULT_CSS = """ @@ -103,9 +105,9 @@ def title_status(self) -> type[SessionStatus]: return AttentionSessionStatus row = self.row if row is not None and row.shown: - return self.dispatch_sync(row.status) + return self.status_paint.dispatch_sync(row.status) if self.turns is not None: - return self.dispatch_sync(self.turns.owner.presentation_status) + return self.status_paint.dispatch_sync(self.turns.owner.presentation_status) return IdleSessionStatus def on_mount(self) -> None: From 54f07baca41df486fc69c5cc963c8b2f089fbe5c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 00:26:14 -0400 Subject: [PATCH 3/4] Decide title paint once from Core status without a role mapping table --- src/toad/widgets/session_details.py | 33 ++++++++++------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/toad/widgets/session_details.py b/src/toad/widgets/session_details.py index c2cf45131..fc2fda009 100644 --- a/src/toad/widgets/session_details.py +++ b/src/toad/widgets/session_details.py @@ -4,16 +4,10 @@ from typing import TYPE_CHECKING -from agent_comms.mro_dispatch import handles -from agent_comms.thread_presentation import ( - AttentionThreadPresentationStatus, IdleThreadPresentationStatus, - WorkingThreadPresentationStatus, -) from textual.content import Content from textual.reactive import var from textual.widgets import Collapsible, Static -from toad.core.projection import MroProjection from toad.line_blocks import LineRole, MutedRole, NoticeRole, WorkingRole from toad.widgets.input_delivery import DetailAction, InputDeliveryBar from toad.widgets.native_history import NativeHistory @@ -26,7 +20,7 @@ from toad.transcript_publication import TranscriptPresentation -class SessionDetails(Collapsible, ObservedThreadActivity, MroProjection): +class SessionDetails(Collapsible, ObservedThreadActivity): """The observed-thread capability and disclosure share one status view. Core owns status for an observed thread, native or CLI. A standalone ACP @@ -65,18 +59,6 @@ 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 - @property def title_role(self) -> type[LineRole]: """Derive existing theme paint from the original status sources.""" @@ -86,9 +68,16 @@ def title_role(self) -> type[LineRole]: return NoticeRole row = self.row if row is not None and row.shown: - return self.dispatch_sync(row.status) - if self.turns is not None: - return self.dispatch_sync(self.turns.owner.presentation_status) + 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 def on_mount(self) -> None: From c68b42cac51417f11116a77482f3edb07ee740c3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 00:37:26 -0400 Subject: [PATCH 4/4] Pin typed thread presentation to merged Core #943 --- pyproject.toml | 2 +- uv.lock | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 402feec0d..ab9d33799 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 = "47f9856b6762553ca10ef94e26de85b2ba069c0d" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "ca7b57b3d10ee4e769e9fe2c8ff33ab540f8536f" } 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/uv.lock b/uv.lock index 91bd434fa..2c4a3f03e 100644 --- a/uv.lock +++ b/uv.lock @@ -21,10 +21,12 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=47f9856b6762553ca10ef94e26de85b2ba069c0d#47f9856b6762553ca10ef94e26de85b2ba069c0d" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=ca7b57b3d10ee4e769e9fe2c8ff33ab540f8536f#ca7b57b3d10ee4e769e9fe2c8ff33ab540f8536f" } dependencies = [ + { name = "aiohttp" }, { name = "metaclass-registry" }, { name = "nominal-refactor-audit" }, + { name = "pydantic" }, ] [package.optional-dependencies] @@ -304,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=47f9856b6762553ca10ef94e26de85b2ba069c0d" }, + { name = "agent-comms", extras = ["acp"], git = "https://github.com/OpenHCSDev/agent-comms.git?rev=ca7b57b3d10ee4e769e9fe2c8ff33ab540f8536f" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, @@ -1023,7 +1025,7 @@ wheels = [ [[package]] name = "nominal-refactor-audit" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/NominalRefactorAdvisor.git?subdirectory=skills%2Frefactor-audit&rev=9a98d72e10cd7ebae688b2e70d096f0ddc9a4c10#9a98d72e10cd7ebae688b2e70d096f0ddc9a4c10" } +source = { git = "https://github.com/OpenHCSDev/NominalRefactorAdvisor.git?subdirectory=skills%2Frefactor-audit&rev=120c04fb9a0eb4afafab6d621d1c5fd421bb7552#120c04fb9a0eb4afafab6d621d1c5fd421bb7552" } [[package]] name = "notify-py"