Skip to content
Closed
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
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
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.cadence_widget = Static("\n".join(order.cadence for order in card.orders), markup=False)

def compose(self) -> ComposeResult:
yield from super().compose()
# Outside Collapsible's contents: the push cadence stays visible when closed.
yield self.cadence_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.cadence_widget.update("\n".join(order.cadence for order in card.orders))


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 for alarm in model.alarms)
or "No stalled orders."))

@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
Loading