Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f59aa13
Delete the duplicated channel Active participants line
trissim Oct 16, 2026
f56e62d
Require explicit kind declarations and keep status widgets view-owned
trissim Oct 16, 2026
61f8097
Read the DM status label from its Core answer
trissim Oct 16, 2026
112978b
Pin the complete runtime-info status contract
trissim Oct 16, 2026
7fd0448
Supply DM session details history and status source
trissim Oct 16, 2026
6f36e0e
Merge pull request #695 from OpenHCSDev/fix/dm-session-details-constr…
trissim Oct 16, 2026
7836100
Pin runtime-info-owned status behavior
trissim Oct 16, 2026
51ae288
Pass the owning runtime to Core native inventory lookup
trissim Oct 16, 2026
a05486e
Merge pull request #700 from OpenHCSDev/fix/mcp-launch-runtime
trissim Oct 16, 2026
ddf6b9f
Render retained Core context readings without copied measurement state
trissim Oct 16, 2026
37434ef
Compose DM activity in its declared conversation kind
trissim Oct 16, 2026
119b15a
Bracket live status with the shared themed activity bar
trissim Oct 16, 2026
4037288
Read both medium bars from the conversation working owner
trissim Oct 16, 2026
c6a359b
Declare accepted event types on native subscriptions
trissim Oct 16, 2026
11bd0e7
Merge pull request #703 from OpenHCSDev/fix/typed-core-subscriptions
trissim Oct 16, 2026
4dbaf25
Merge pull request #702 from OpenHCSDev/feat/themed-medium-throbber
trissim Oct 16, 2026
1857aaf
Merge current main and remove competing unavailable context presentation
trissim Oct 16, 2026
3e22e68
Pin the final Core context presentation checkpoint
trissim Oct 16, 2026
a64bca9
Keep the generated Core requirement array valid
trissim Oct 16, 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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ dependencies = [
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 = "00a3d74d4b1ab8962941d84a764b3e8c2e57013d" }
ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "aaa35af5948c6025d33fc178f6bd687d9dc5c322" }
agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "2fb445b94fc22ed58a365709f4a873ca53e4a303" }
textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "18f4e7a67403d56846ef5abf55c93e3e0639be33" }
textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" }
textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }
Expand Down
5 changes: 3 additions & 2 deletions src/toad/acp/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from toad.acp.client_files import FileClientRequestOwner
from toad.acp.tool_calls import SessionToolCalls
from toad.acp.agent_configuration import ModelConfigurationSetting, ThinkingConfigurationSetting
from toad.acp.context_measurement import ContextMeasurement, ContextUnavailable
from toad.acp.context_measurement import ContextMeasurement
from toad.acp.agent_log import AgentLog
from toad.acp.agent_process import AgentProcess
from toad.acp.agent_controller import AgentController
Expand All @@ -24,6 +24,7 @@
)
from agent_comms.acp_failure import ACPFailure
from agent_comms.field_codec import FieldCodec
from agent_comms.context_reading import UnmeasuredContext
from agent_comms.goal_actions import GoalAction
from agent_comms.goal_presentation import GoalSnapshot
from agent_comms.goals import Goal
Expand Down Expand Up @@ -111,7 +112,7 @@ def __init__(
self.presentation.log_path.unlink(missing_ok=True)
else:
self.presentation.log_path = paths.get_log() / log_filename
self.context_measurement = ContextUnavailable("Native owner has not reported context usage")
self.context_measurement = ContextMeasurement.saved(UnmeasuredContext())

@property
def command(self) -> str | None:
Expand Down
6 changes: 3 additions & 3 deletions src/toad/acp/comms_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
TranscriptSnapshotUpdate,
TurnChangedUpdate,
)
from agent_comms.context_reading import UnmeasuredContext
from agent_comms.mro_dispatch import MroDispatch, handles
from agent_comms.thread_presentation import ThreadPresentation
from toad.acp.client_session import ClientSessionRequest
from toad.acp.context_measurement import ContextMeasurement

from toad.core import events as core_events

Expand Down Expand Up @@ -122,7 +124,6 @@ def coordination_changed(self, update: CoordinationChangedUpdate) -> None:
from dataclasses import replace
from pathlib import Path

from .context_measurement import ContextMeasurement
from agent_comms.route_selection import child_root

agent = self.agent
Expand All @@ -144,8 +145,7 @@ def goal_changed(self, update: GoalChangedUpdate) -> None:

@handles(CompactionChangedUpdate)
def compaction_changed(self, update: CompactionChangedUpdate) -> None:
from .context_measurement import ContextUnavailable
self.agent.context_measurement = ContextUnavailable("Native context measurement is pending")
self.agent.context_measurement = ContextMeasurement.saved(UnmeasuredContext())
self.agent.update_status_line()
self.agent.events.publish(core_events.CommsUpdated(update, self.session_id))

Expand Down
35 changes: 10 additions & 25 deletions src/toad/acp/context_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from math import floor
from typing import NamedTuple
from rich.text import Text as RichText
from agent_comms.context_reading import ContextReading, UnmeasuredContext

class Cost(NamedTuple):
"""A cost with associated currency."""
Expand All @@ -24,41 +25,27 @@ def __format__(self, _specifier: str) -> str:


class ContextMeasurement(ABC):
available = False

@abstractmethod
def status(self) -> RichText: ...

@classmethod
def saved(cls, usage):
if usage is None:
return ContextUnavailable("Native owner has not reported context usage")
return SavedContextMeasurement(usage.used, usage.size)
def saved(cls, reading: ContextReading) -> ContextMeasurement:
return SavedContextMeasurement(reading)

@classmethod
def live(cls, used, size, cost):
# The ACP adapter uses zero usage after compaction as unmeasured.
# Decode that boundary sentinel once; never paint it as known 0%.
if used <= 0 or size <= 0:
return ContextUnavailable("Native owner has not supplied a usable context measurement")
return SavedContextMeasurement(UnmeasuredContext())
return LiveContextMeasurement(used, size, Cost(cost.amount, cost.currency) if cost else None)


@dataclass(frozen=True)
class ContextUnavailable(ContextMeasurement):
reason: str

def status(self) -> RichText:
return RichText(f"Context unavailable · {self.reason}")


@dataclass(frozen=True)
class MeasuredContext(ContextMeasurement):
class LiveContextMeasurement(ContextMeasurement):
used: int
size: int
cost: Cost | None = None
available = True
source_label = ""

@property
def percentage_used(self):
Expand All @@ -71,16 +58,14 @@ def percentage_display(self):
def status(self) -> RichText:
parts = [RichText.assemble(f"{self.used / 1000:.1f}K (",
(self.percentage_display, "bold"), ")")]
if self.source_label:
parts.append(RichText(self.source_label))
if self.cost is not None:
parts.append(RichText.assemble((f"{self.cost}", "bold")))
return RichText(" • ").join(parts)


class LiveContextMeasurement(MeasuredContext):
pass

@dataclass(frozen=True)
class SavedContextMeasurement(ContextMeasurement):
reading: ContextReading

class SavedContextMeasurement(MeasuredContext):
source_label = "last response"
def status(self) -> RichText:
return RichText(self.reading.status_text())
62 changes: 31 additions & 31 deletions src/toad/conversation_kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from agent_comms.declared_family import DeclaredFamily
from agent_comms.comms import Comms
from agent_comms.message_page import MessagePage
from textual.app import ComposeResult
from toad.widgets.native_history import NativeHistory
from toad.widgets.session_details import SessionDetails


class ConversationKind(DeclaredFamily, affix="Conversation"):
Expand Down Expand Up @@ -84,16 +87,18 @@ def read_only(cls, catalog, target):
return False

@classmethod
async def update_roster(cls, view, comms):
pass
@abstractmethod
async def update_mentions(cls, view, comms) -> None: ...

@classmethod
async def agent_info(cls, comms, target):
return None
@abstractmethod
async def status_text(cls, comms, target, *, read_only: bool) -> str: ...

@classmethod
@abstractmethod
def activity_widget(cls, view): ...
def compose_activity(cls, view) -> ComposeResult:
"""Compose this kind's activity using the existing view state."""
...

@classmethod
@abstractmethod
Expand Down Expand Up @@ -150,10 +155,12 @@ def current_identity(cls, page: MessagePage) -> tuple | None:
)

@classmethod
def activity_widget(cls, view):
from toad.widgets.channel_participants import ChannelParticipants
def compose_activity(cls, view) -> ComposeResult:
return ()

return ChannelParticipants()
@classmethod
async def status_text(cls, comms, target, *, read_only: bool) -> str:
return "Read-only saved view" if read_only else ""

@classmethod
def prompt(cls, target):
Expand All @@ -166,14 +173,11 @@ def read_only(cls, catalog, target):
return catalog.is_view_target(target)

@classmethod
async def update_roster(cls, view, comms):
from toad.widgets.channel_participants import ChannelParticipants
async def update_mentions(cls, view, comms):
from toad.widgets.channel_prompt import ChannelPrompt

# The composer consumes membership and thread presentation, not the
# actor's inbox counts or channel activity clocks. Borrow the observed
# service's sidebar model rather than reading the same registry,
# activity and catalog again after every channel page.
# Mention completion consumes the observer's membership snapshot,
# not another registry/catalog read after every channel page.
history = view.message_history
source = history.source_snapshot()
target = view.target
Expand All @@ -182,19 +186,10 @@ async def update_roster(cls, view, comms):
snapshot = answer.require_snapshot()
observed = access.observation
if observed is None or observed.service is not comms:
raise ValueError("Channel roster belongs to another Comms service")
rows = observed.sidebar.threads.rows

def prepare():
people = tuple(rows[person.thread.name] for person in snapshot.participants(target)
if person.thread.name in rows)
return (ChannelParticipants.prepare_participants(people),
snapshot.mention_candidates(target))

participants, mentions = await view.app.preparation.run_thread(prepare)
raise ValueError("Channel mentions belong to another Comms service")
mentions = await view.app.preparation.run_thread(snapshot.mention_candidates, target)
if not await history.source_is_current(source) or view.target != target:
return
view.query_one(ChannelParticipants).update_participants(*participants)
view.query_one(ChannelPrompt).set_mention_candidates(mentions)

@classmethod
Expand Down Expand Up @@ -278,11 +273,16 @@ def label(cls, target: str) -> str:
return f"@{target}"

@classmethod
def activity_widget(cls, view):
from toad.widgets.session_details import SessionDetails
def compose_activity(cls, view) -> ComposeResult:
view.thread_activity = SessionDetails(
history=NativeHistory(),
status_source=lambda: view.thread_activity.row,
)
yield view.thread_activity

view.thread_activity = SessionDetails()
return view.thread_activity
@classmethod
async def update_mentions(cls, view, comms) -> None:
"""Direct-message composers have no channel mention roster."""

@classmethod
def prompt(cls, target):
Expand All @@ -291,8 +291,8 @@ def prompt(cls, target):
return Prompt(simple_input=True, placeholder=cls.placeholder(target))

@classmethod
async def agent_info(cls, comms, target):
return await asyncio.to_thread(comms.configuration.answer, target)
async def status_text(cls, comms, target, *, read_only: bool) -> str:
return (await asyncio.to_thread(comms.configuration.answer, target)).status_label

@classmethod
def seen_page(cls, history, seen):
Expand Down
9 changes: 6 additions & 3 deletions src/toad/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ class Subscription:
"""A subscriber owns this resource for as long as it wants publications."""

stream: CoreEventStream
event_type: type[CoreEvent]
listener: InitVar[Callable[[CoreEvent, Subscription], object]]
_listener: Callable[[], Callable[[CoreEvent, Subscription], object] | None] = field(init=False, repr=False)

Expand Down Expand Up @@ -412,11 +413,13 @@ def publisher(self) -> object:
raise RuntimeError("The publication resource outlived its original owner")
return publisher

def subscribe(self, listener: Callable[[CoreEvent, Subscription], object]) -> Subscription:
subscription = Subscription(self, listener)
def subscribe(self, event_type: type[CoreEvent],
listener: Callable[[CoreEvent, Subscription], object]) -> Subscription:
subscription = Subscription(self, event_type, listener)
self.subscriptions.add(subscription)
return subscription

def publish(self, event: CoreEvent) -> None:
for subscription in tuple(self.subscriptions):
subscription.publish(event)
if isinstance(event, subscription.event_type):
subscription.publish(event)
12 changes: 7 additions & 5 deletions src/toad/core_event_carrier.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

def subscribe_core(self, stream: CoreEventStream) -> Subscription:
subscription = stream.subscribe(self.post_core_event)
subscription = stream.subscribe(CoreEvent, self.post_core_event)
self._core_subscriptions.add(subscription)
return subscription

Expand All @@ -67,9 +67,10 @@ async def consume_handlers(self, message, handlers, *args, **kwargs):
def observe_core(self, stream: CoreEventStream) -> Subscription:
"""Broadcast observations terminate at their original native recipient."""
for subscription in self._core_subscriptions:
if subscription.stream is stream and subscription.active:
if (subscription.stream is stream and subscription.event_type is CoreEvent
and subscription.active):
return subscription
subscription = stream.subscribe(self.post_core_observation)
subscription = stream.subscribe(CoreEvent, self.post_core_observation)
self._core_subscriptions.add(subscription)
return subscription

Expand All @@ -82,7 +83,8 @@ def retire_core_observations(self, stream: CoreEventStream) -> None:
if subscription.stream is stream:
self.retire_core(subscription)

def observe_core_callback(self, stream: CoreEventStream, callback) -> Subscription:
def observe_core_callback(self, stream: CoreEventStream,
event_type: type[CoreEvent], callback) -> Subscription:
"""An original deferred operation receives relief inside its native pump."""
recipient = ref(self)
try:
Expand All @@ -104,7 +106,7 @@ def publish(event, subscription) -> None:
message.bubble = False
receiver.post_message(message)

subscription = stream.subscribe(publish)
subscription = stream.subscribe(event_type, publish)
self._core_subscriptions.add(subscription)
return subscription

Expand Down
3 changes: 2 additions & 1 deletion src/toad/mcp_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pathlib import Path

from agent_comms.field_codec import FieldCodec
from agent_comms.launch_runtime import LaunchRuntime
from toad import mcp_declarations


Expand Down Expand Up @@ -53,7 +54,7 @@ def installed_mcp_command() -> tuple[str, str]:
"""Use MCP shipped inside the same verified native package as Comms."""
from agent_comms.native_pi import NativePiRpcLaunch

package = NativePiRpcLaunch.package_for_command("pi")
package = NativePiRpcLaunch.package_for_command("pi", LaunchRuntime.running())
node = shutil.which("node")
if node is None:
raise FileNotFoundError("Node is unavailable")
Expand Down
3 changes: 2 additions & 1 deletion src/toad/session_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ def direct_messages(self, target: str | None = None):

def bind_events(self) -> None:
self.app.subscribe_core(self.events)
self.app.observe_core_callback(self.app.coordination_access.events, self.observed)
self.app.observe_core_callback(self.app.coordination_access.events,
core_events.CoordinationObserved, self.observed)

async def reveal(self) -> None:
from toad.widgets.comms_sidebar import CommsSidebar
Expand Down
4 changes: 3 additions & 1 deletion src/toad/transcript_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from textual.worker import Worker, WorkerCancelled
from textual.await_complete import AwaitComplete
from toad.widgets.history_anchor import HeldReaderDestination
from toad.core.events import CoordinationObserved

if TYPE_CHECKING:
from toad.screens.session_view import SessionOrigin
Expand Down Expand Up @@ -380,7 +381,8 @@ def defer(self, publication: TranscriptPublication) -> None:
return
self.observation.close()
self.observation.callback(view.retire_core,
view.observe_core_callback(view.app.coordination_access.events, self.resume))
view.observe_core_callback(view.app.coordination_access.events,
CoordinationObserved, self.resume))

def resume(self, _event=None) -> bool:
"""Resume retained work only at an existing source/preparation signal."""
Expand Down
4 changes: 3 additions & 1 deletion src/toad/transcript_source_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from textual.worker import Worker, WorkerCancelled
from textual.await_complete import AwaitComplete
from toad.core_event_carrier import CoreEventReceiver
from toad.core.events import CoordinationObserved
from toad.transcript_state import TranscriptState, RetiredSourceTranscript, ParkedSourceTranscript, WorkingTranscript, LatestViewportRequest
from toad.transcript_preparation import PreparedPageSource
from toad.core.source_events import TranscriptSourceWorkFinished
Expand Down Expand Up @@ -183,7 +184,8 @@ def resume(_event) -> None:
self.finish_source_work(operation)

self.retire_core_observations(self.app.coordination_access.events)
self.observe_core_callback(self.app.coordination_access.events, resume)
self.observe_core_callback(self.app.coordination_access.events,
CoordinationObserved, resume)

def supersede_source_work(
self, operation: WorkingTranscript, request: LatestViewportRequest,
Expand Down
Loading
Loading