Skip to content
29 changes: 29 additions & 0 deletions src/toad/comms_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
SidebarObserved, SidebarUnreadable, ViewsRetired,
StopObserving, StopSidebar, ThreadsObserved,
)
from agent_comms.ui_model.commander import CommanderObserved, StopCommander
from agent_comms.ui_model.sidebar import SidebarModel, SidebarObservation
from agent_comms.ui_model.status import OpenThreadStatus
from toad.core.events import CoreEventStream, CoordinationObserved, ExternalTranscriptChanged, OpenTabsChanged, LinkContextChanged, ObservedServiceChanged, ThreadRowsChanged
Expand Down Expand Up @@ -76,6 +77,9 @@ def uses(self, service) -> bool:

def tabs(self, sessions):
return {entry.mode: entry.unobserved_tab() for entry in sessions.members}

def observe_commander(self, wanted) -> None:
"""An unobserved route has no process accepting page interest."""
def require(self, access, selected):
return access.open_selected(selected)
def references_for(self, root):
Expand Down Expand Up @@ -143,6 +147,8 @@ def show_sidebar(self, view):

def activate(self, access):
self.observe_sidebar(None)
for view in tuple(access.commander_views):
view.commander_failed(self.error)

def observe_sidebar(self, wanted):
access = self.access
Expand Down Expand Up @@ -190,6 +196,7 @@ def require(self, access, selected):
def activate(self, access):
self.loop.add_reader(self.process.fileno(), self.receive, access)
access.update_sidebar_interest()
access.update_commander_interest()
self.thread_interest(access)
self.updates.callback(self.answer_reads)
self.updates.enter_context(access.app.events.subscribe(OpenTabsChanged, self.views_changed))
Expand Down Expand Up @@ -243,6 +250,9 @@ async def current_references(self, access, root):
def thread_interest(self, access):
self.process.request(ObserveThreads(access.events.observed_threads(str(self.service.root))))

def observe_commander(self, wanted) -> None:
self.process.request(wanted)

def observe_sidebar(self, wanted):
self.process.request(wanted if wanted is not None else StopSidebar())

Expand Down Expand Up @@ -270,6 +280,7 @@ def __init__(self, app: ToadApp) -> None:
self.thread_status = OpenThreadStatus(app.call_next)
# Views showing the sidebar model now, and consumers waiting for current rows.
self.sidebar_views: WeakSet[object] = WeakSet()
self.commander_views: WeakSet[object] = WeakSet()
self.reference_views: WeakSet[object] = WeakSet()
self.sidebar_waiters: list[tuple[ObserveSidebar, asyncio.Future[SidebarObservation]]] = []
self.sidebar_interest: ObserveSidebar | None = None
Expand Down Expand Up @@ -323,6 +334,24 @@ def revision_observed(self, result: RevisionObserved, observed: Observing) -> No
if result.revision.changed_since(ExternalTranscriptsView(), previous):
self.events.publish(ExternalTranscriptChanged())

def show_commander(self, view) -> None:
self.commander_views.add(view)
self.update_commander_interest()

def stop_commander(self, view) -> None:
self.commander_views.discard(view)
self.update_commander_interest()

def update_commander_interest(self) -> None:
request = next((view.request for view in self.commander_views), StopCommander())
self.observation.observe_commander(request)

@handles(CommanderObserved)
def commander_observed(self, result: CommanderObserved, observed: Observing) -> None:
for view in tuple(self.commander_views):
if view.request == result.request:
result.model.answer(view.commander_observed, lambda: None, view.commander_failed)

@handles(SidebarObserved)
def sidebar_observed(self, result: SidebarObserved, observed: Observing) -> None:
if result.request != self.sidebar_interest:
Expand Down
Empty file added src/toad/py.typed
Empty file.
123 changes: 123 additions & 0 deletions src/toad/screens/commander.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Commander cards are views of Core's observed model, not another order board."""
from __future__ import annotations

from dataclasses import replace

from textual import containers, on
from textual.app import ComposeResult
from textual.content import Content
from textual.widgets import Collapsible, Static
from toad.screens.modal import ModalScreen

from agent_comms.thread_identity import ThreadIncarnation
from agent_comms.ui_model.commander import (
CommanderModel,
ObserveCommander,
ReadCommanderIdentity,
UnitCard,
)


class CommanderUnit(Collapsible):
DEFAULT_CSS = """
CommanderUnit { border: none; padding: 0; margin: 0; }
CommanderUnit > CollapsibleTitle { height: 1; text-overflow: ellipsis; }
CommanderUnit Static { height: auto; }
CommanderUnit .commander-turns { text-wrap: nowrap; text-overflow: ellipsis; }
"""

def __init__(self, card: UnitCard) -> None:
self.detail_widget = Static(card.detail.text, markup=False)
self.turns_widget = Static("\n".join(card.detail.turn_lines), markup=False,
classes="commander-turns")
super().__init__(self.detail_widget, self.turns_widget,
title=Content(card.line), collapsed=True)
self.unit = card.unit
self.position = 0
self.momentum_widget = Static("\n".join(item.line for item in card.momentum), markup=False)

def compose(self) -> ComposeResult:
yield from super().compose()
# Outside Collapsible's contents: the forecast stays visible when closed.
yield self.momentum_widget

def present(self, card: UnitCard, position: int) -> None:
self.position = position
self.title = Content(card.line)
self.detail_widget.update(card.detail.text)
self.turns_widget.update("\n".join(card.detail.turn_lines))
self.momentum_widget.update("\n".join(item.line for item in card.momentum))


class CommanderScreen(ModalScreen[None]):
DEFAULT_CSS = """
CommanderScreen { align: center middle; }
CommanderScreen #commander-page {
width: 98%; height: 95%; border: round $accent; background: $surface;
}
CommanderScreen #commander-status { height: 1; padding: 0 1; }
CommanderScreen #commander-board { height: 3fr; }
CommanderScreen #commander-alarms { height: 1fr; border-top: solid $accent; padding: 0 1; }
CommanderScreen #commander-alarms Static { height: auto; }
"""
BINDINGS = [("escape", "dismiss", "Close commander")]

def __init__(self, commander: str) -> None:
super().__init__()
self.commander_name = commander
self.rows: dict[ThreadIncarnation, CommanderUnit] = {}
self.request: ObserveCommander

def compose(self) -> ComposeResult:
with containers.Vertical(id="commander-page"):
yield Static("Opening commander…", id="commander-status", markup=False)
yield containers.VerticalScroll(id="commander-board")
with containers.VerticalScroll(id="commander-alarms"):
yield Static("Alarms", id="commander-alarm-lines", markup=False)

async def on_mount(self) -> None:
access = self.app.coordination_access
try:
observed = await access.preparation.run_thread(access.observation.observed)
self.request = await observed.process.read(ReadCommanderIdentity(
str(observed.service.root), self.commander_name))
access.show_commander(self)
except (ValueError, OSError, RuntimeError) as error:
self.commander_failed(error)

def on_unmount(self) -> None:
self.app.coordination_access.stop_commander(self)

def commander_failed(self, error: Exception) -> None:
self.query_one("#commander-status", Static).update(f"Commander unavailable: {error}")
for row in self.rows.values():
row.remove()
self.rows.clear()
self.query_one("#commander-alarm-lines", Static).update("Alarms unavailable.")

def commander_observed(self, model: CommanderModel) -> None:
board = self.query_one("#commander-board", containers.VerticalScroll)
current = {card.unit for card in model.units}
for unit in self.rows.keys() - current:
self.rows.pop(unit).remove()
for position, card in enumerate(model.units):
if card.unit not in self.rows:
row = self.rows[card.unit] = CommanderUnit(card)
row.position = position
board.mount(row)
else:
self.rows[card.unit].present(card, position)
board.call_after_refresh(board.sort_children, key=lambda row: row.position)
self.query_one("#commander-status", Static).update(
f"Commander {model.commander.name} | {len(model.units)} units | "
"Overdue first · Enter/click a unit to expand · Esc to return")
self.query_one("#commander-alarm-lines", Static).update(
"Alarms\n" + ("\n".join(alarm.line for alarm in model.alarms)
or "No stalled orders or recorded send/delivery alarms."))

@on(Collapsible.Expanded, "CommanderUnit")
@on(Collapsible.Collapsed, "CommanderUnit")
def expansion_changed(self, event: Collapsible.Toggled) -> None:
self.request = replace(self.request, expanded=frozenset(
row.unit for row in self.rows.values() if not row.collapsed))
self.app.coordination_access.show_commander(self)
20 changes: 20 additions & 0 deletions src/toad/slash_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ def parse(cls, arguments: str) -> Self:
return cls()


@dataclass(frozen=True)
class CommanderCommand(SlashCommand, LocalCommand):
help = "Open Core's live commander board and alarms"
hint = "<commander-thread>"
commander: str = ""

@classmethod
def parse(cls, arguments: str) -> Self:
names = shlex.split(arguments)
if len(names) != 1:
raise ValueError("Use /commander <commander-thread>.")
return cls(names[0])

async def apply(self, conversation: Conversation) -> bool:
from toad.screens.commander import CommanderScreen

conversation.app.push_screen(CommanderScreen(self.commander))
return True


class LoginCommand(NoArgumentsCommand):
help = "Connect a provider using the agent's native login UI"

Expand Down
9 changes: 5 additions & 4 deletions src/toad/widgets/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,8 +910,7 @@ def compose(self) -> ComposeResult:
status=Conversation.status,
)
yield self.throbber
self.goal_bar: GoalBar = GoalBar(self, throbber=self.throbber, prompt=prompt)
yield self.goal_bar
yield GoalBar(self, throbber=self.throbber, prompt=prompt)
yield prompt

@property
Expand Down Expand Up @@ -1622,11 +1621,13 @@ async def post_welcome(self) -> None:
def on_resize(self) -> None:
# A goal can retain its own size while the surrounding viewport changes.
# Reapply its user-selected height against the new viewport bound.
self.goal_bar.update_document_height()
for goal_bar in self.query(GoalBar):
goal_bar.update_document_height()

def on_mouse_down(self, event: events.MouseDown) -> None:
self._mouse_down_offset = event.screen_offset
self.goal_bar.begin_separator_resize(event)
for goal_bar in self.query(GoalBar):
goal_bar.begin_separator_resize(event)

def on_click(self, event: events.Click) -> None:
from toad.screens.session_view import SessionView
Expand Down
Loading