diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 9d4642ac1..229dba14b 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -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 @@ -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): @@ -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 @@ -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)) @@ -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()) @@ -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 @@ -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: diff --git a/src/toad/py.typed b/src/toad/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py new file mode 100644 index 000000000..3e25f0e44 --- /dev/null +++ b/src/toad/screens/commander.py @@ -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) diff --git a/src/toad/slash_command.py b/src/toad/slash_command.py index 951453de2..3bdbcd699 100644 --- a/src/toad/slash_command.py +++ b/src/toad/slash_command.py @@ -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: str = "" + + @classmethod + def parse(cls, arguments: str) -> Self: + names = shlex.split(arguments) + if len(names) != 1: + raise ValueError("Use /commander .") + 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" diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index aa401f9c5..ea74bf4fd 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -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 @@ -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