Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
55cf042
Point stale queue and live-output tests at the owners that replaced t…
trissim Oct 30, 2026
a3f8032
Complete conversation entry queue-evidence default
trissim Oct 31, 2026
f644438
Bind queue inspections and control failures to their source owners
trissim Oct 31, 2026
0904932
Derive live outgoing queue evidence from its notification widget
trissim Oct 31, 2026
3280727
Preserve goal refusals instead of rebuilding errors from text
trissim Oct 31, 2026
d67ae7c
Keep editor refusals distinct from malformed owner replies
trissim Oct 31, 2026
2174993
Exercise maintenance refusal through the shared read worker
trissim Oct 31, 2026
2dc2102
Integrate existing #1378 without restoring retired imports
trissim Oct 31, 2026
08c49f1
Require the existing event and application owner contracts
trissim Oct 31, 2026
a317050
Merge commit '08c49f166ab5e41e60c984705f92689ba1f5952a' into integ/qu…
trissim Oct 31, 2026
04a23f3
Expose queue and modal runtime dependencies from their owners
trissim Oct 31, 2026
7bab354
Merge commit '04a23f3679f341b5f632c88ca3eb222c23f79541' into integ/qu…
trissim Oct 31, 2026
8725402
Enable headless notifications through the existing test application o…
trissim Oct 31, 2026
c30970c
Merge commit '8725402f1818ef7ad2c005554de329ce58623f03' into integ/qu…
trissim Oct 31, 2026
0262c1d
Use the declared muted blend for ordinary commander rows
trissim Oct 31, 2026
51190be
Merge commit '0262c1d8ebbb1ea37f00a07ae48d889ad5f6bb76' into integ/qu…
trissim Oct 31, 2026
d1de211
Complete conversation entry queue-evidence default
trissim Oct 31, 2026
1fc638a
Bind queue inspections and control failures to their source owners
trissim Oct 31, 2026
1d07912
Derive live outgoing queue evidence from its notification widget
trissim Oct 31, 2026
15fe81f
Preserve goal refusals instead of rebuilding errors from text
trissim Oct 31, 2026
c17125b
Keep editor refusals distinct from malformed owner replies
trissim Oct 31, 2026
40fdd21
Exercise maintenance refusal through the shared read worker
trissim Oct 31, 2026
d952839
Require the existing event and application owner contracts
trissim Oct 31, 2026
60dc33c
Expose queue and modal runtime dependencies from their owners
trissim Oct 31, 2026
1e64ad1
Enable headless notifications through the existing test application o…
trissim Oct 31, 2026
9e28f91
Admit real toasts before screen composition and use the owner batch l…
trissim Oct 31, 2026
e98bbde
Merge commit '9e28f9187728f39bd1ee6ca4ec37cb22df1c9b8a' into integ/qu…
trissim Oct 31, 2026
cc9b83e
Admit the real goal button hit target before its one click
trissim Oct 31, 2026
7ee18e2
Merge commit 'cc9b83e42354cdd1c89fdebed9b3653c4c62141b' into integ/qu…
trissim Oct 31, 2026
384abcc
Click the declared GoalControl instead of assuming Button
trissim Oct 31, 2026
e072594
Reserve notification space instead of covering active controls
trissim Oct 31, 2026
ce213ae
Declare the native send-now interruption in its journey
trissim Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions src/toad/acp/agent_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
encode_request,
)
from agent_comms.acp_failure import ACPFailure, RequestFailure
from agent_comms.coordination_errors import Refused
from agent_comms.runtime import RuntimeConnection, socket_path
from agent_comms.transcripts import TranscriptReadIdentity
from agent_comms.order_tools import InputDeliveryRead
Expand Down Expand Up @@ -411,8 +410,6 @@ def resolve():
result = await proxy.request(method, **params)
self.require_owner(coordination, authority)
return result
except (RuntimeError, Refused) as error:
raise ValueError(str(error)) from error
finally:
await proxy.close()

Expand Down
6 changes: 6 additions & 0 deletions src/toad/block_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from abc import abstractmethod
from collections.abc import Iterable
from functools import cached_property
from typing import TYPE_CHECKING, Callable, ClassVar, cast

Expand All @@ -15,6 +16,7 @@

from toad.block_content import BlockContent
from toad.frame_presentation import AnimationEffects
from toad.notification_status import MessageQueueEntry
from toad.widgets.presentation_window import NewerEnd, OlderEnd, PageEnd

if TYPE_CHECKING:
Expand Down Expand Up @@ -402,6 +404,10 @@ def notification_identity_for(self, references: frozenset[MessageReference]) ->
"""Live entries have no captured BUS display cut; source rows override with theirs."""
return NoDisplaySelection()

def queue_entries(self) -> Iterable[MessageQueueEntry]:
"""Live entries supply no captured BUS delivery rows; source rows override with theirs."""
return ()

@cached_property
def block_cursor(self) -> BlockCursor:
return AtomicBlockCursor(self)
Expand Down
9 changes: 5 additions & 4 deletions src/toad/conversation_submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from toad.command_catalog import CommandCatalog
from toad.acp.agent import Agent
from toad.acp.queue_attachment import QueueActionRefused
from toad.submission_queue import QueueEvidence
from toad.submission_queue import QueueEvidence, QueueSource
from toad.core.input_events import UserInputSubmitted
from toad.core_event_carrier import CoreEventMessage
from toad.input_decision import InputDecision, InputRefused
Expand Down Expand Up @@ -334,10 +334,11 @@ def reset(self):
self.publish_pending()

def send_now(self):
source = self.queue_source
source = self.view.queue_source
presentation = self.view.agent_binding.presentation
return self.view.run_worker(
partial(self._send_now, source, self.view.agent_binding.presentation,
source.queue_scope(self.view.agent_binding.presentation)),
partial(self._send_now, source, presentation,
source.queue_scope(presentation)),
group="send-queued-now", exclusive=True,
)

Expand Down
26 changes: 13 additions & 13 deletions src/toad/goal_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import ClassVar, TYPE_CHECKING
from weakref import ref

from agent_comms.coordination_errors import Refused
from agent_comms.errors import GoalRefused
from agent_comms.nominal.declared_family import DeclaredFamily
from agent_comms.goal_actions import ClearGoalAction, GoalAction
from agent_comms.goal_presentation import ControlledExecution, GoalExecution
Expand Down Expand Up @@ -103,7 +105,7 @@ async def apply(cls, session):

async def save(text: str) -> None:
if not session.owns(agent):
raise ValueError("The goal presentation changed; reopen its editor.")
raise GoalRefused("The goal presentation changed; reopen its editor.")
await session.write(agent, agent.edit_goal(goal, text))

observed = await view.app.preparation.run_thread(view.app.coordination_access.observation.observed)
Expand Down Expand Up @@ -159,15 +161,18 @@ async def activate(self, action: type[GoalInteraction]) -> None:
if not action.enabled(self):
view.feedback.flash(view.goal_display.heading(view.goal_execution))
return
await action.apply(self)
try:
await action.apply(self)
except Refused as error:
if not self.owns(agent):
raise
view.app.show_error(error, "Goal action failed")

async def change(self, action: type[GoalAction], text: str = "") -> None:
view = self.view
if not view.is_attached:
raise ValueError("The goal presentation changed; reopen its controls.")
agent = view.agent_binding
if not self.owns(agent):
raise ValueError("The goal presentation changed; reopen its controls.")
raise GoalRefused("The goal presentation changed; reopen its controls.")
# The existing Agent boundary encodes the declared goal operation.
# Goal state governs continuation, never interruption of an active turn.
await self.write(agent, agent.update_goal(action, text))
Expand All @@ -177,15 +182,10 @@ async def change(self, action: type[GoalAction], text: str = "") -> None:
async def write(self, agent, operation):
"""Every write settles by reading the owner, including rejected writes."""
try:
try:
return await operation
finally:
if self.owns(agent):
await self.view.goal_observation.refresh()
except (OSError, ValueError) as error:
return await operation
finally:
if self.owns(agent):
raise ValueError(self.view.goal_display.action_failure(error)) from error
raise
await self.view.goal_observation.refresh()

def close(self) -> None:
modal = self.modal
Expand Down
27 changes: 4 additions & 23 deletions src/toad/screens/goal_details.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
"""A full-height, scrollable projection of a persistent goal."""
from toad.core_event_carrier import CoreEventReceiver, CoreEventMessage
from metaclass_registry.mro_dispatch import handles

from datetime import datetime

from agent_comms.goal_history import RecordedGoalHistory
from textual.app import ComposeResult
from textual.containers import Vertical, VerticalScroll
from toad.screens.modal import DismissibleScreen
from toad.screens.source_modal import SourceModal
from toad.widget_actions import DismissAction
from textual.widgets import Button, Static

from toad.navigation_target import SelectTarget
from toad.goal_interaction import GoalSession
from toad.project_path_owner import ProjectPathOwner
from toad.widgets.goal_text import GoalText


class GoalDetails(CoreEventReceiver, ProjectPathOwner, DismissibleScreen[None]):
class GoalDetails(SourceModal):
AUTO_FOCUS = "#goal-document"
DEFAULT_CSS = """
GoalDetails { align: center middle; background: $background 40%; }
Expand All @@ -37,16 +32,8 @@ def __init__(self, source: GoalSession, *, history: RecordedGoalHistory):
self.failures = history.failures

@property
def project_root(self):
return ProjectPathOwner.containing(self.source.view).project_root

@property
def coordination_root(self):
return ProjectPathOwner.containing(self.source.view).coordination_root

@property
def link_context_events(self):
return ProjectPathOwner.containing(self.source.view).link_context_events
def source_view(self):
return self.source.view

@property
def goal_display(self):
Expand Down Expand Up @@ -109,9 +96,3 @@ def _update_current(self) -> None:
progress = self.query_one("#goal-current-progress", GoalText)
progress.update_goal_text(f"Progress: {goal.progress}")
progress.display = goal.shows_progress

@handles(SelectTarget)
def open_target(self, event: SelectTarget, message: CoreEventMessage) -> None:
message.stop()
self.dismiss(None)
self.source.view.publish_core(event)
2 changes: 1 addition & 1 deletion src/toad/screens/goal_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ async def save(self, text: str) -> None:
save_button.disabled = True
try:
await self.on_save(text.strip())
except (Refused, ValueError) as error:
except Refused as error:
self.query_one("#goal-edit-error", Static).update(str(error))
else:
self.dismiss(text.strip())
Expand Down
23 changes: 14 additions & 9 deletions src/toad/screens/queue.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Inspect the captured queue source through the actual conversation view."""
from __future__ import annotations

from collections.abc import Awaitable, Callable

Expand All @@ -11,13 +12,12 @@

from toad.acp.queue_attachment import QueueActionRefused
from toad.agent_presentation import AgentPresentation
from toad.conversation_submission import ConversationSubmissions
from toad.line_blocks import SpanColumns
from toad.screens.modal import DismissibleScreen
from toad.submission_queue import QueueEvidence, QueueSource
from toad.screens.source_modal import SourceModal
from toad.widgets.tool_content import ToolContent


class QueueInspection(DismissibleScreen[None]):
class QueueInspection(SourceModal):
"""One screen captures the source, presentation and scope it actually displays."""

DEFAULT_CSS = """
Expand All @@ -28,11 +28,16 @@
QueueInspection #queue-error { height: auto; color: $error; }
"""

def __init__(self, source: QueueSource, evidence: QueueEvidence, changed: Callable[[], None]) -> None:
def __init__(self, submissions: ConversationSubmissions) -> None:
super().__init__()
self.source, self.evidence = source, evidence
self.scope = source.queue_scope(evidence.presentation)
self.changed = changed
self.submissions = submissions
self.source = submissions.view.queue_source
self.evidence = submissions.queue_evidence
self.scope = self.source.queue_scope(self.evidence.presentation)

@property
def source_view(self):
return self.submissions.view

def compose(self) -> ComposeResult:
with Vertical():
Expand All @@ -55,7 +60,7 @@
except (QueueActionRefused, RequestError) as error:
self.query_one("#queue-error", Static).update(str(error))
return
self.changed()
self.submissions.publish_pending()
self.dismiss()

@on(Button.Pressed, "#queue-send-now")
Expand Down
35 changes: 35 additions & 0 deletions src/toad/screens/source_modal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Rich modals retain their source's project and navigation capabilities."""
from __future__ import annotations

from abc import abstractmethod

from metaclass_registry.mro_dispatch import handles
from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver
from toad.navigation_target import SelectTarget
from toad.project_path_owner import ProjectPathOwner
from toad.screens.modal import DismissibleScreen

class SourceModal(CoreEventReceiver, ProjectPathOwner, DismissibleScreen[None]):
"""Rich modal content keeps the original view's project and navigation."""

@property
@abstractmethod
def source_view(self) -> CoreEventReceiver: ...

@property
def project_root(self):
return ProjectPathOwner.containing(self.source_view).project_root

@property
def coordination_root(self):
return ProjectPathOwner.containing(self.source_view).coordination_root

@property
def link_context_events(self):
return ProjectPathOwner.containing(self.source_view).link_context_events

@handles(SelectTarget)
def open_target(self, event: SelectTarget, message: CoreEventMessage) -> None:
message.stop()
self.dismiss(None)
self.source_view.publish_core(event)
4 changes: 2 additions & 2 deletions src/toad/submission_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@

from toad.acp.queue_attachment import QueueActionRefused
from toad.agent_presentation import AgentPresentation
from toad.block_navigation import ConversationEntry
from toad.line_blocks import MutedRole, Span
from toad.notification_status import DetailLines, MessageQueueEntry
from toad.widgets.message_notifications import SourceNotifications


@dataclass(frozen=True)
Expand All @@ -25,7 +25,7 @@ class QueueEvidence:
presentation: AgentPresentation
delivering: tuple[str, ...]
transport: str
read_targets: Callable[[], tuple[SourceNotifications, ...]]
read_targets: Callable[[], tuple[ConversationEntry, ...]]

@cached_property
def entries(self) -> tuple[MessageQueueEntry, ...]:
Expand Down
9 changes: 9 additions & 0 deletions src/toad/toad.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,15 @@ HelpPanel {



/* Notifications reserve their own row instead of owning clicks over the editor. */
ToastRack {
layer: default;
dock: none;
split: bottom;
max-height: 25vh;
margin-bottom: 0;
}

Toast {
margin: 0 0;
padding: 0 1;
Expand Down
3 changes: 1 addition & 2 deletions src/toad/widgets/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1693,8 +1693,7 @@ def queue_observation_changed(self, event: QueueEvidenceChanged) -> None:
self.queue_evidence_changed()

def open_queue_menu(self) -> None:
self.app.push_screen(QueueInspection(
self.queue_source, self.submissions.queue_evidence, self.submissions.publish_pending))
self.app.push_screen(QueueInspection(self.submissions))

@handles(PreferenceChanged)
async def _settings_changed(self, event: PreferenceChanged) -> None:
Expand Down
12 changes: 10 additions & 2 deletions src/toad/widgets/message_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
from textual.content import Content
from textual.widgets import Collapsible

from agent_comms.maintenance_barrier import MaintenanceHeldError
from agent_comms.notification_reads import LiveNotificationRead
from agent_comms.ui_model.observation import ReadNotifications
from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection
from agent_comms.message_reference import MessageReference
from toad.app_node import ToadNode
from toad.messages import QueueEvidenceChanged

from toad.notification_status import (
Expand Down Expand Up @@ -93,11 +95,17 @@ def start(self, marker: VisibleReadMarking) -> None:
class ReadWork(PaintedRead):
work: Awaitable[None]

async def run(self, marker: VisibleReadMarking) -> None:
try:
await self.work
except MaintenanceHeldError as error:
marker.app.show_error(error, "Read not marked", severity="warning")

def start(self, marker: VisibleReadMarking) -> None:
marker._read_worker = marker.run_worker(self.work, group="painted-read")
marker._read_worker = marker.run_worker(self.run(marker), group="painted-read")


class VisibleReadMarking:
class VisibleReadMarking(ToadNode):
"""One painted-read operation at a time, for pagers and native/CLI sessions."""

def __init__(self, *args, **kwargs):
Expand Down
5 changes: 4 additions & 1 deletion src/toad/widgets/outgoing_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from toad.widgets.message_notifications import MessageNotifications
from toad.widgets.linked_rows import MessageRowBlock
from toad.widgets.message_row_owner import MessageRowOwner
from toad.notification_status import CheckedNotifications, NotificationStatus
from toad.notification_status import CheckedNotifications, MessageQueueEntry, NotificationStatus
from toad.widgets.committed_presentation import CommitParticipant, OutgoingMessageClaim


Expand All @@ -40,6 +40,9 @@ def message_reference(self):
def notification_targets(self):
return (self,) if self.message_dividers and self.display else ()

def queue_entries(self) -> tuple[MessageQueueEntry, ...]:
return (MessageQueueEntry(self.message_reference, self.query_one(MessageNotifications).status),)

@property
def notification_references(self):
return (self.message_reference,)
Expand Down
5 changes: 1 addition & 4 deletions tests/model/test_goal_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def test_each_goal_state_answers_visibility_control_and_snapshot():
goal = published_goal("Keep the objective", "display-state")
idle = IdleGoalExecution()
for state, visible, enabled, snapshot in (
(NoGoal(), False, False, NoGoal()), # nothing to control: Core answers a control with "This goal has been cleared."
(NoGoal(), False, False, NoGoal()),
(goal, True, True, goal),
(GoalUnavailable(goal), True, False, goal),
(GoalUnavailable(NoGoal()), True, False, NoGoal()),
Expand Down Expand Up @@ -39,9 +39,6 @@ def heading(self, execution):
def standby(self, execution):
return False

def action_failure(self, error):
return str(error)

state = OwnerReconnecting()
assert state.visible
assert not state.can_control
Expand Down
Loading
Loading