Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
2 changes: 1 addition & 1 deletion src/toad/acp/attachment_presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

@dataclass(frozen=True)
class CursorPresentation:
status: type[CursorStatus] | None
status: type[CursorStatus]


@dataclass(frozen=True)
Expand Down
2 changes: 1 addition & 1 deletion src/toad/acp/projection_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions src/toad/agent_presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -61,7 +61,7 @@ def queue(self):

@property
def attachments(self):
return AgentAttachmentView(None, 0)
return AgentAttachmentView(HiddenCursorStatus, 0)


class ACPAgentPresentation(AgentPresentation):
Expand Down
11 changes: 11 additions & 0 deletions src/toad/conversation_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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: ...

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 19 additions & 4 deletions src/toad/private_native_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,33 @@
from typing import ClassVar

from agent_comms.declared_family import DeclaredFamily
from agent_comms.thread_presentation import ThreadPresentationStatus


class CursorStatus(DeclaredFamily, affix="CursorStatus"):
"""One decoded cursor state owns its full UI presentation."""

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…"
Expand All @@ -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 = (
Expand Down
11 changes: 5 additions & 6 deletions src/toad/session_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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)
9 changes: 9 additions & 0 deletions src/toad/session_presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down
16 changes: 6 additions & 10 deletions src/toad/widgets/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1732,17 +1732,13 @@ 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
self.app.push_screen(details)
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,
Expand Down
15 changes: 6 additions & 9 deletions src/toad/widgets/input_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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."
)
Expand Down
10 changes: 5 additions & 5 deletions src/toad/widgets/native_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading