diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index cf46144e6..aa8459ca8 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -1,237 +1,364 @@ -"""Expandable views of the commander's published heartbeat frames.""" +"""Native views of Core's declaration-owned commander groups and tables.""" from __future__ import annotations import time +from dataclasses import replace -from agent_comms.commander_hud import ( - BoardRow, CommanderHud, CommanderUnit, HudRow, QuietUnits, -) +from agent_comms.commander_hud import CommanderHud, CommanderUnit, HudRecord, HudSection from agent_comms.coordination_errors import DeferredRead +from agent_comms.disclosure_text import ( + AccentTone, DisclosureEntry, DisclosureKey, DisclosureText, ErrorTone, HudTone, + MutedTone, RootKey, SuccessTone, ViewTable, WarningTone, +) from agent_comms.errors import RelationViolationError, RouteChangedError, ToolRefusedError +from agent_comms.task_attachments.commander_pings import AlarmGroup, AlarmSection from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.observation import ReadCommanderUnit, UnreadableHistory +from agent_comms.ui_model.unit_state import HudGroup from agent_comms.work_branches import WorkBranchError from metaclass_registry.mro_dispatch import MroDispatch, handles -from rich.cells import cell_len from textual import on, work from textual.app import ComposeResult -from textual.containers import Vertical, VerticalGroup, VerticalScroll +from textual.containers import HorizontalGroup, Vertical, VerticalGroup, VerticalScroll from textual.content import Content -from textual.widgets import Button, Collapsible, Static +from textual.message import Message +from textual.style import Style +from textual.widgets import Button, Checkbox, Collapsible, Input, Static +from ui_theme.scheme import ( + AccentColour, ErrorColour, ForegroundMutedColour, SuccessColour, ThemeColour, WarningColour, +) +from toad.app_node import ToadNode from toad.core.events import CommanderHudChanged, CommanderHudFailed from toad.core_event_carrier import CoreEventReceiver from toad.screens.modal import DismissibleScreen from toad.widget_actions import DismissAction -from toad.app_node import ToadNode -from toad.widgets.sidebar_tree import KeyedRows -from toad.widgets.declared_disclosure import ( - DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, +from toad.widgets.declared_disclosure import DeclaredDisclosure +from toad.widgets.declared_table import ( + DeclaredTable, EmptyTableSelection, SelectedTableRecord, SelectionView, TableSelection, ) +from toad.widgets.sidebar_tree import KeyedRows + + +class UnitTone(MroDispatch): + """Bind Core's shared tone tiers to native theme colours, not group semantics.""" + + @classmethod + def of(cls, tone: type[HudTone]) -> type[ThemeColour]: + return cls.handler_for(tone)(tone) + + @handles(MutedTone) + @staticmethod + def ordinary(tone: type[MutedTone]) -> type[ThemeColour]: + return ForegroundMutedColour + + @handles(WarningTone) + @staticmethod + def attention(tone: type[WarningTone]) -> type[ThemeColour]: + return WarningColour + + @handles(AccentTone) + @staticmethod + def accent(tone: type[AccentTone]) -> type[ThemeColour]: + return AccentColour + + @handles(SuccessTone) + @staticmethod + def success(tone: type[SuccessTone]) -> type[ThemeColour]: + return SuccessColour + + @handles(ErrorTone) + @staticmethod + def error(tone: type[ErrorTone]) -> type[ThemeColour]: + return ErrorColour + + +class CommanderTable(DeclaredTable[HudRecord]): + class Selected(Message): + def __init__(self, selection: TableSelection[HudRecord]) -> None: + self.selection = selection + super().__init__() + def row_tone(self, record: HudRecord) -> type[ThemeColour]: + return UnitTone.of(record.hud_group.tone) -class HudRowText(MroDispatch): - """Core's ordinary rows; units and board entries have their own disclosures.""" + def preferred_selection(self) -> TableSelection[HudRecord]: + return self.query_ancestor(CommanderFrameView).selected - def lines(self, row: HudRow) -> tuple[str, ...]: - return type(self).handler_for(type(row))(self, row) + def selection_changed(self) -> None: + frame = self.query_ancestor(CommanderFrameView) + selection = self.selected + if selection.key == frame.selected.key: + self.post_message(self.Selected(selection)) - @handles(HudRow) - def row(self, row: HudRow) -> tuple[str, ...]: - return (row.line(),) + def selection_highlighted(self) -> None: + self.post_message(self.Selected(self.selected)) - @handles(CommanderUnit, QuietUnits, BoardRow) - def disclosed(self, row: HudRow) -> tuple[str, ...]: - return () +class CommanderGroup(KeyedRows[type[HudRecord]], ToadNode, VerticalGroup): + """One native band really contains its rows, including ready unheld plan phases.""" -class CommanderUnitCard(ToadNode, Collapsible): - """Retain the unit's disclosure while Core replaces its current card.""" + def __init__(self, section: HudSection) -> None: + self.section = section + self.heading = Static("", markup=False, classes="hud-group-heading") + self.table_body = VerticalGroup() + super().__init__() + self.call_after_refresh(self.render_section) + + def compose(self) -> ComposeResult: + yield self.heading + yield self.table_body + + @property + def member_container(self) -> VerticalGroup: + return self.table_body + + @property + def rows(self) -> dict[type[HudRecord], CommanderTable]: + return {table.source.record: table for table in self.table_body.query_children(CommanderTable)} + + @property + def sources(self) -> dict[type[HudRecord], ViewTable[HudRecord]]: + return {table.record: table for table in self.section.tables} + + def create_row(self, key: type[HudRecord]) -> CommanderTable: + return CommanderTable(self.sources[key]) + + def update_row(self, key: type[HudRecord], row: CommanderTable) -> None: + source = self.sources[key] + if row.source != source: + row.present(source) + + def present(self, section: HudSection) -> None: + self.section = section + self.call_after_refresh(self.render_section) + + def render_section(self) -> None: + colour = self.app.colours.colour(UnitTone.of(self.section.group.tone)) + self.heading.update(Content(self.section.heading()).stylize(Style(foreground=colour))) + self.display = bool(self.section.records) + keys = tuple(self.sources) + self.reconcile_keys(keys, changed=frozenset(keys)) + + +class CommanderAlarm(ToadNode, Collapsible): + """Core's compact section stays visible; expansion reveals every original record.""" DEFAULT_CSS = """ - CommanderUnitCard { height: auto; border: none; padding: 0; margin: 0; } - CommanderUnitCard > CollapsibleTitle { height: auto; padding: 0; } - CommanderUnitCard > Contents { padding: 0 1; height: auto; } - CommanderUnitCard Static { height: auto; } - CommanderUnitCard Button { dock: none; width: auto; margin: 0; } - CommanderUnitCard .detail-status { color: $text-muted; } - CommanderUnitCard.-needs-hand > CollapsibleTitle { color: $warning; } + CommanderAlarm > CollapsibleTitle { width: 1fr; } """ - def __init__(self, commander: ThreadIncarnation, card: CommanderUnit) -> None: + def __init__(self, group: type[AlarmGroup]) -> None: + self.group = group + self.original = Static("", markup=False) + super().__init__(self.original, title=group.heading(), collapsed=True) + self.display = False + + def present(self, section: AlarmSection, now: float) -> None: + colour = self.app.colours.colour(UnitTone.of(section.group.tone)) + self.title = Content("\n".join(CommanderHud.alarm_text((section,), now))).stylize( + Style(foreground=colour)) + self.original.update("\n".join(CommanderHud.alarm_detail((section,), now))) + self.display = True + + +class CommanderDetails(SelectionView[HudRecord], MroDispatch, ToadNode, VerticalGroup): + """The selected native record owns its one bounded original-details read.""" + + def __init__(self, commander: ThreadIncarnation) -> None: self.commander = commander - self.card = card - self.summary = DeclaredDisclosure(DisclosureEntry( - RootKey(type(card)), "Current card", DisclosedValue.of(card), - )) - self.detail_status = Static("Open to read original orders and activity.", - markup=False, classes="detail-status") - self.original_details = VerticalGroup() - super().__init__( - self.summary, self.detail_status, - Button("Refresh original details", classes="refresh-unit", compact=True), - self.original_details, title="", collapsed=True, - ) - self.present(card) - - # The title's disclosure symbol and its space; continued lines indent under the name. - TITLE_INDENT = " " - - def brief_lines(self, width: int) -> str: - """Pack coherent sections, then wrap oversized sections with hanging indentation.""" - width = max(2, width) - lines: list[str] = [] - for part in self.card.brief_parts(): - joined = f"{lines[-1]} | {part}" if lines else part - if lines and cell_len(joined) <= width: - lines[-1] = joined - else: - lines.append(part) - wrapped = [line.plain for section in lines for line in Content(section).wrap(width)] - return f"\n{self.TITLE_INDENT}".join(wrapped) - - def fit_title(self) -> None: - self.title = Content(self.brief_lines(self.size.width - len(self.TITLE_INDENT))) - - def on_resize(self) -> None: - self.fit_title() - - def present(self, card: CommanderUnit) -> None: - changed = card != self.card - self.card = card - self.fit_title() - self.set_class(card.needs_hand(), "-needs-hand") - self.summary.present(DisclosureEntry( - RootKey(type(card)), "Current card", DisclosedValue.of(card), - )) - if changed and self.is_mounted and not self.collapsed: - self.read_detail() + self.selection: TableSelection[HudRecord] = EmptyTableSelection() + self.status = Static("", markup=False) + self.original = VerticalGroup() + self.original.display = False + super().__init__(classes="hud-detail") - def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: - if event.collapsible is self: - event.stop() - self.read_detail() + def compose(self) -> ComposeResult: + yield self.status + yield Button("Refresh original details", classes="refresh-unit", compact=True) + yield self.original + + def present(self, selection: TableSelection[HudRecord]) -> None: + if self.selection == selection: + return + self.selection = selection + selection.present_to(self) + + def on_mount(self) -> None: + self.selection.present_to(self) - def on_collapsible_collapsed(self, event: Collapsible.Collapsed) -> None: - if event.collapsible is self: - event.stop() - self.workers.cancel_group(self, "commander-detail") + def clear_selection(self) -> None: + self.workers.cancel_group(self, "commander-detail") + self.original.display = False + self.status.update("No records match the filter.") + + def show_selection(self, key: DisclosureKey, record: HudRecord) -> None: + self.read_detail(SelectedTableRecord(key, record)) @on(Button.Pressed, ".refresh-unit") def refresh_detail(self, event: Button.Pressed) -> None: event.stop() - self.read_detail() + self.selection.present_to(self) - @work(exclusive=True, group="commander-detail") - async def read_detail(self) -> None: - card = self.card - self.detail_status.update("Reading original orders and recent activity…") + @handles(HudRecord) + async def record_detail(self, card: HudRecord) -> object: + return card + + @handles(CommanderUnit) + async def unit_detail(self, card: CommanderUnit) -> object: access = self.app.coordination_access + observed = await access.preparation.run_thread(access.observation.observed) + return await observed.process.read(ReadCommanderUnit( + str(observed.service.root), self.commander, card.unit, card.order_ids, + )) + + @work(exclusive=True, group="commander-detail") + async def read_detail(self, selection: SelectedTableRecord[HudRecord]) -> None: + self.original.display = False + card = selection.record + label = DisclosureText.describe(card).summary + self.status.update(f"Reading original records for {label}…") try: - observed = await access.preparation.run_thread(access.observation.observed) - request = ReadCommanderUnit( - str(observed.service.root), self.commander, card.unit, - tuple(dict.fromkeys(order.order for order in card.orders)), - ) - detail = await observed.process.read(request) - except ( - DeferredRead, RelationViolationError, RouteChangedError, - ToolRefusedError, UnreadableHistory, WorkBranchError, - ) as error: - if self.is_attached and self.card == card and not self.collapsed: - self.detail_status.update(f"Original details unavailable: {error}") + detail = await type(self).handler_for(type(card))(self, card) + except (DeferredRead, RelationViolationError, RouteChangedError, ToolRefusedError, + UnreadableHistory, WorkBranchError) as error: + if self.is_attached and self.selection == selection: + self.status.update(f"Original details unavailable: {error}") return - if not self.is_attached or self.collapsed or self.card != card: + if not self.is_attached or self.selection != selection: return - entry = DisclosureEntry( - RootKey(type(detail)), "Original details", DisclosedValue.of(detail), - ) - for view in self.original_details.query_children(DeclaredDisclosure): + entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosureText.describe(detail)) + for view in self.original.query_children(DeclaredDisclosure): view.present(entry) break else: - self.original_details.mount(DeclaredDisclosure(entry)) - self.detail_status.update("Original records read; activity uses Core's bounded view.") + self.original.mount(DeclaredDisclosure(entry)) + self.original.display = True + self.status.update("Original records; unit activity is bounded by Core.") + + def on_unmount(self) -> None: + self.workers.cancel_group(self, "commander-detail") -class CommanderFrameView(KeyedRows[ThreadIncarnation], VerticalGroup): - """The existing keyed-row capability owns unit retention and ordering.""" +class CommanderFrameView(KeyedRows[type[HudGroup]], ToadNode, VerticalGroup): + """Keep native groups, filters, selection and original details across frame replacement.""" - row_text = HudRowText() + DEFAULT_CSS = """ + CommanderFrameView .hud-filter { height: auto; } + CommanderFrameView .hud-filter Input { width: 1fr; } + CommanderFrameView .hud-filter Checkbox { width: auto; } + CommanderTable { height: auto; max-height: 50vh; } + CommanderFrameView .hud-detail { height: auto; } + CommanderFrameView .refresh-unit { width: auto; margin: 0; } + CommanderGroup { height: auto; } + CommanderGroup .hud-group-heading { text-style: bold; } + """ def __init__(self, hud: CommanderHud) -> None: self.hud = hud + self.selected: TableSelection[HudRecord] = EmptyTableSelection() + self.shown: dict[type[HudGroup], HudSection] = {} self.heading = Static("", markup=False, classes="hud-commander") self.status = Static("", markup=False, classes="hud-load") - self.units = VerticalGroup() - self.footer = Static("", markup=False) - self.board_text = Static("", markup=False) - self.board = Collapsible(self.board_text, title="", collapsed=True) + self.groups = VerticalGroup() + self.filter = Input(placeholder="Filter commander records") + self.needs_hand = Checkbox("Needs hand") + self.details = CommanderDetails(hud.commander) + self.alarms = {group: CommanderAlarm(group) for group in AlarmGroup.members_with(AlarmGroup)} super().__init__() + self.call_after_refresh(self.render_frame) + + def compose(self) -> ComposeResult: + yield self.heading + yield self.status + with HorizontalGroup(classes="hud-filter"): + yield self.filter + yield self.needs_hand + yield self.groups + yield from self.alarms.values() + yield self.details + + def filtered_sections(self) -> dict[type[HudGroup], HudSection]: + text = self.filter.value.casefold() + shown: dict[type[HudGroup], HudSection] = {} + for section in self.hud.groups: + tables: list[ViewTable[HudRecord]] = [] + for table in section.tables: + records = tuple(record for record, cells in zip(table.records, table.texts) + if (not self.needs_hand.value or record.needs_hand()) + and text in "\n".join(cells).casefold()) + tables.append(table if records == table.records else replace(table, records=records)) + shown[section.group] = HudSection(section.group, tuple(tables)) + return shown @property def member_container(self) -> VerticalGroup: - return self.units + return self.groups @property - def rows(self) -> dict[ThreadIncarnation, CommanderUnitCard]: - return {row.card.unit: row for row in self.units.query_children(CommanderUnitCard)} + def rows(self) -> dict[type[HudGroup], CommanderGroup]: + return {view.section.group: view for view in self.groups.query_children(CommanderGroup)} - @property - def cards(self) -> dict[ThreadIncarnation, CommanderUnit]: - return {card.unit: card for card in self.hud.units} + def create_row(self, key: type[HudGroup]) -> CommanderGroup: + return CommanderGroup(self.shown[key]) - def create_row(self, key: ThreadIncarnation) -> CommanderUnitCard: - return CommanderUnitCard(self.hud.commander, self.cards[key]) + def update_row(self, key: type[HudGroup], row: CommanderGroup) -> None: + row.present(self.shown[key]) - def update_row(self, key: ThreadIncarnation, row: CommanderUnitCard) -> None: - row.present(self.cards[key]) + @on(Input.Changed) + @on(Checkbox.Changed) + def filter_changed(self) -> None: + self.call_after_refresh(self.render_frame) - def compose(self) -> ComposeResult: - yield self.heading - yield self.status - yield self.board - yield self.units - yield self.footer - - def on_mount(self) -> None: - self.present(self.hud) - self.reconcile_units() - - def reconcile_units(self) -> None: - keys = tuple(card.unit for card in self.hud.units) - self.reconcile_keys(keys, changed=frozenset(keys)) + @on(CommanderTable.Selected) + def highlight_record(self, event: CommanderTable.Selected) -> None: + event.stop() + self.selected = event.selection + self.details.present(self.selected) def present(self, hud: CommanderHud) -> None: self.hud = hud - self.heading.update(f"Commander {hud.commander.name}") - self.status.update("\n".join(( - str(hud.load), *hud.heartbeat.aged_line("heartbeat", time.time()), - ))) - self.footer.update("\n".join( - line for row in hud.rows() for line in self.row_text.lines(row) - )) - self.board.title = Content(f"{len(hud.board)} standing requests and alerts") - self.board_text.update("\n".join(BoardRow(entry).line() for entry in hud.board)) - self.board.display = bool(hud.board) - if self.is_mounted: - self.reconcile_units() + self.call_after_refresh(self.render_frame) + + def focus_selection(self) -> None: + for table in self.query(CommanderTable): + # Focus needs the prepared semantic record, not allocated native row keys. + if self.selected.key in table.source.entities: + table.focus(scroll_visible=False) + break + + def render_frame(self) -> None: + focused = any(table.has_focus for table in self.query(CommanderTable)) + self.heading.update(f"Commander {self.hud.commander.name}") + now = time.time() + self.status.update(self.hud.status_strip(now)) + for view in self.alarms.values(): + view.display = False + for section in self.hud.alarms: + self.alarms[section.group].present(section, now) + self.shown = self.filtered_sections() + records = {key: record for section in self.shown.values() for table in section.tables + for key, record in table.entities.items()} + self.selected = TableSelection[HudRecord].from_records(records, self.selected.key) + self.details.present(self.selected) + keys = tuple(self.shown) + self.reconcile_keys(keys, changed=frozenset(keys)) + if focused: + self.call_after_refresh(self.focus_selection) -class CommanderPage( - KeyedRows[ThreadIncarnation], CoreEventReceiver, DismissibleScreen[None], -): - """Current Core frames preserve disclosures, focus and the underlying conversation.""" +class CommanderPage(KeyedRows[ThreadIncarnation], CoreEventReceiver, DismissibleScreen[None]): + """Current Core frames preserve table selection, focus and the conversation below.""" DEFAULT_CSS = """ CommanderPage { align: center middle; background: $background 40%; } CommanderPage > Vertical { - width: 95%; height: auto; max-height: 95%; padding: 0 1; + width: 95%; height: 95%; padding: 0 1; border: solid $primary; background: $surface; } CommanderPage #hud-error { display: none; height: auto; color: $error; } - CommanderPage #hud-rows { height: auto; max-height: 100%; scrollbar-gutter: stable; } + CommanderPage #hud-rows { height: 1fr; scrollbar-gutter: stable; } CommanderPage CommanderFrameView { height: auto; } CommanderPage Static { height: auto; } CommanderPage .hud-commander { text-style: bold reverse; } @@ -250,10 +377,7 @@ def member_container(self) -> VerticalScroll: @property def rows(self) -> dict[ThreadIncarnation, CommanderFrameView]: - return { - row.hud.commander: row - for row in self.member_container.query_children(CommanderFrameView) - } + return {row.hud.commander: row for row in self.member_container.query_children(CommanderFrameView)} @property def frames(self) -> dict[ThreadIncarnation, CommanderHud]: @@ -287,10 +411,8 @@ async def hud_changed(self, event: CommanderHudChanged) -> None: error = self.query_one("#hud-error", Static) error.update("") error.display = False - self.huds = tuple( - hud for hud in event.huds - if not self.commander_name or hud.commander.name == self.commander_name - ) + self.huds = tuple(hud for hud in event.huds + if not self.commander_name or hud.commander.name == self.commander_name) keys = tuple(hud.commander for hud in self.huds) self.reconcile_keys(keys, changed=frozenset(keys)) self.query_one("#hud-empty", Static).display = not self.huds diff --git a/src/toad/widgets/declared_disclosure.py b/src/toad/widgets/declared_disclosure.py index 13b7de523..32f5b5af1 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -1,14 +1,9 @@ -"""Lazy disclosures of declared values, not a second handwritten record schema.""" +"""Lazy native paint over Core's prepared disclosure tree.""" from __future__ import annotations -from collections.abc import Collection, Mapping, Sequence -from dataclasses import dataclass, is_dataclass -from functools import singledispatchmethod -from typing import ClassVar, Hashable - -from agent_comms.field_codec import DisclosureIdentity, FieldCodec, FieldPlan -from python_introspect import declared_field_label +from agent_comms.disclosure_text import DisclosureEntry, DisclosureKey, DisclosureText, StructuredText +from metaclass_registry.mro_dispatch import MroDispatch, handles from textual import events from textual.containers import VerticalGroup from textual.content import Content @@ -17,185 +12,8 @@ from toad.widgets.sidebar_tree import KeyedRows -class DisclosureKey: - """Nominal native-row identity; its members carry only their differing components.""" - - -@dataclass(frozen=True) -class RecordKey(DisclosureKey): - owner: type - - -class RootKey(RecordKey): - """The whole record, not one of its fields.""" - - -@dataclass(frozen=True) -class FieldKey(RecordKey): - name: str - - -@dataclass(frozen=True) -class EntityKey(RecordKey): - parts: tuple[object, ...] - - -class AnonymousKey(DisclosureKey): - """A source without declared entity identity cannot inherit another item's view.""" - - -@dataclass(frozen=True) -class MappingKey(DisclosureKey): - key: Hashable - - -@dataclass(frozen=True) -class DisclosedValue: - """One borrowed value. Shape is recovered only at this structural boundary.""" - - value: object - - @singledispatchmethod - @classmethod - def of(cls, value: object) -> DisclosedValue: - if is_dataclass(value): - identity = tuple( - plan for plan in FieldCodec.field_plans(type(value)) - if DisclosureIdentity.read(plan.annotation, False) - ) - if identity: - return IdentifiedRecordValue(value, identity) - return RecordValue(value) - return cls(value) - - @of.register(type) - @classmethod - def _class(cls, value: type) -> DisclosedValue: - return ClassValue(value) - - @of.register(Mapping) - @classmethod - def _mapping(cls, value: Mapping[Hashable, object]) -> DisclosedValue: - return MappingValue(value) - - @of.register(Sequence) - @classmethod - def _sequence(cls, value: Sequence[object]) -> DisclosedValue: - return SequenceValue(value) - - @of.register(str) - @of.register(bytes) - @classmethod - def _scalar(cls, value: str | bytes) -> DisclosedValue: - return cls(value) - - def sequence_key(self) -> DisclosureKey: - return AnonymousKey() - - def identity_part(self) -> object: - return self.value - - def summary(self, width: int) -> str: - # Preview work is bounded by the visible label, not the history body's size. - return str(self.value)[:width].partition("\n")[0] - - def show(self, view: DeclaredDisclosure) -> None: - view.body.display = True - view.members.display = False - view.body.update(str(self.value)) - - -@dataclass(frozen=True) -class ClassValue(DisclosedValue): - value: type - - def summary(self, width: int) -> str: - return self.value.__name__ - - -class StructuredValue(DisclosedValue): - def children(self) -> tuple[DisclosureEntry, ...]: - raise NotImplementedError - - def show(self, view: DeclaredDisclosure) -> None: - view.body.display = False - view.members.display = True - entries = self.children() - view.reconcile_entries(entries) - - -class RecordValue(StructuredValue): - def summary(self, width: int) -> str: - return type(self.value).__name__ - - def children(self) -> tuple[DisclosureEntry, ...]: - return tuple( - DisclosureEntry( - FieldKey(type(self.value), plan.name), - declared_field_label(plan.annotation) or plan.key.replace("_", " ").capitalize(), - DisclosedValue.of(plan.current(self.value)), - ) - for plan in FieldCodec.field_plans(type(self.value)) if plan.records - ) - - -@dataclass(frozen=True) -class IdentifiedRecordValue(RecordValue): - identity: tuple[FieldPlan, ...] - - def sequence_key(self) -> EntityKey: - return EntityKey(type(self.value), tuple( - DisclosedValue.of(plan.current(self.value)).identity_part() - for plan in self.identity - )) - - def identity_part(self) -> EntityKey: - return self.sequence_key() - - -@dataclass(frozen=True) -class CollectionValue(StructuredValue): - value: Collection[object] - noun: ClassVar[str] = "item" - - def summary(self, width: int) -> str: - count = len(self.value) - return f"{count} {self.noun}{'s' if count != 1 else ''}" - - -@dataclass(frozen=True) -class SequenceValue(CollectionValue): - value: Sequence[object] - - def children(self) -> tuple[DisclosureEntry, ...]: - entries = [] - for index, value in enumerate(self.value): - disclosed = DisclosedValue.of(value) - entries.append(DisclosureEntry(disclosed.sequence_key(), str(index + 1), disclosed)) - return tuple(entries) - - -@dataclass(frozen=True) -class MappingValue(CollectionValue): - value: Mapping[Hashable, object] - noun = "field" - - def children(self) -> tuple[DisclosureEntry, ...]: - return tuple( - DisclosureEntry(MappingKey(key), str(key), DisclosedValue.of(value)) - for key, value in self.value.items() - ) - - -@dataclass(frozen=True) -class DisclosureEntry: - key: DisclosureKey - label: str - value: DisclosedValue - - -class DeclaredDisclosure(KeyedRows[DisclosureKey], Collapsible): - """Native widgets own expansion/focus; only open branches acquire child widgets.""" +class DeclaredDisclosure(KeyedRows[DisclosureKey], MroDispatch, Collapsible): + """Core owns children and identity; native widgets own expansion and focus.""" DEFAULT_CSS = """ DeclaredDisclosure { height: auto; border: none; padding: 0; margin: 0; } @@ -227,7 +45,7 @@ def update_row(self, key: DisclosureKey, row: DeclaredDisclosure) -> None: row.present(self.pending_entries[key]) def reconcile_entries(self, entries: tuple[DisclosureEntry, ...]) -> None: - # Reconciliation borrows the immediate declarations only for this call. + # Reconciliation borrows the immediate prepared children only for this call. by_key = {entry.key: entry for entry in entries} if len(by_key) != len(entries): raise ValueError(f"Repeated declared identity in {self.entry.label}") @@ -237,9 +55,25 @@ def reconcile_entries(self, entries: tuple[DisclosureEntry, ...]) -> None: finally: del self.pending_entries + @handles(DisclosureText) + def scalar(self, source: DisclosureText) -> None: + self.body.display = True + self.members.display = False + self.body.update(source.summary) + + @handles(StructuredText) + def structured(self, source: StructuredText) -> None: + self.body.display = False + self.members.display = True + self.reconcile_entries(source.children) + + def show_source(self) -> None: + source = self.entry.value + type(self).handler_for(type(source))(self, source) + def fit_title(self) -> None: width = max(1, self.size.width - 2) - title = Content(f"{self.entry.label[:width]}: {self.entry.value.summary(width)}") + title = Content(f"{self.entry.label}: {self.entry.value.summary}".partition("\n")[0]) self.title = title.truncate(width, ellipsis=True) def on_resize(self, event: events.Resize) -> None: @@ -249,9 +83,9 @@ def present(self, entry: DisclosureEntry) -> None: self.entry = entry self.fit_title() if self.is_mounted and not self.collapsed: - entry.value.show(self) + self.show_source() def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: if event.collapsible is self: event.stop() - self.entry.value.show(self) + self.show_source() diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py new file mode 100644 index 000000000..8c76d636c --- /dev/null +++ b/src/toad/widgets/declared_table.py @@ -0,0 +1,175 @@ +"""Native keyed rows over Core's declaration-owned table presentation.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass, field + +from agent_comms.disclosure_text import AnonymousKey, DisclosureKey, ViewTable +from textual.content import Content +from textual.style import Style +from textual.widgets import DataTable +from textual.widgets._data_table import RowKey +from ui_theme.scheme import ForegroundColour, ThemeColour + +from toad.widget_actions import MovesCursor, OpensSelected + + +class SelectionView[R]: + """A native selection consumer implements both states without another wrapper.""" + + @abstractmethod + def clear_selection(self) -> None: + raise NotImplementedError + + @abstractmethod + def show_selection(self, key: DisclosureKey, record: R) -> None: + raise NotImplementedError + + +@dataclass(frozen=True) +class TableSelection[R](ABC): + """Own empty/selected presentation dispatch, never transport or reader work.""" + + @property + def key(self) -> DisclosureKey: + return AnonymousKey() + + @abstractmethod + def present_to(self, view: SelectionView[R]) -> None: ... + + def place_cursor(self, table: DeclaredTable[R]) -> None: + """An empty selection has no native cursor to place.""" + + @classmethod + def from_records(cls, records: Mapping[DisclosureKey, R], + *preferred: DisclosureKey) -> TableSelection[R]: + """Use already derived identities, otherwise choose the first actual record.""" + for key in preferred: + if key in records: + return SelectedTableRecord(key, records[key]) + for key, record in records.items(): + return SelectedTableRecord(key, record) + return EmptyTableSelection() + + +@dataclass(frozen=True) +class EmptyTableSelection[R](TableSelection[R]): + def present_to(self, view: SelectionView[R]) -> None: + view.clear_selection() + + +@dataclass(frozen=True, slots=True) +class SelectedTableRecord[R](TableSelection[R]): + key: DisclosureKey = field() + record: R + + def present_to(self, view: SelectionView[R]) -> None: + view.show_selection(self.key, self.record) + + def place_cursor(self, table: DeclaredTable[R]) -> None: + native = table.entity_keys[self.key] + table.move_cursor(row=table.get_row_index(native), animate=False, scroll=False) + + +class DeclaredTable[R](MovesCursor, OpensSelected, DataTable[Content]): + """Core owns columns and measurement; DataTable owns rows, geometry and repaint.""" + + def __init__(self, source: ViewTable[R]) -> None: + super().__init__(cursor_type="row", show_row_labels=False) + self.records: dict[RowKey, SelectedTableRecord[R]] = {} + self.entity_keys: dict[DisclosureKey, RowKey] = {} + self.present(source) + + def present(self, source: ViewTable[R]) -> None: + self.source = source + # The native message pump runs this after mount/layout, including updates + # supplied by a parent's Mount before its child's Mount has run. + self.call_after_refresh(self.reconcile_source) + + def row_tone(self, record: R) -> type[ThemeColour]: + return ForegroundColour + + def selection_changed(self) -> None: + """The actual selected source record changed during reconciliation.""" + + def selection_highlighted(self) -> None: + """Native navigation may intentionally choose a different semantic record.""" + self.selection_changed() + + def preferred_selection(self) -> TableSelection[R]: + """A shared reader may retain its record when it moves between tables.""" + return self.selected + + @property + def selected(self) -> TableSelection[R]: + if not self.row_count: + return EmptyTableSelection() + key = self.coordinate_to_cell_key(self.cursor_coordinate).row_key + return self.records[key] + + def reconcile_source(self) -> None: + previous = self.selected + offset = self.scroll_offset + entities = self.source.entities + selected = TableSelection[R].from_records( + entities, self.preferred_selection().key, previous.key, + ) + available = self.size.width - self.scrollbar_size_vertical + layout = self.source.layout(available - self.cell_padding * 2 * len(self.source.columns)) + widths = layout.widths(max(1, available - self.cell_padding * 2 * len(layout.columns))) + self.show_header = layout.show_header + rows = [] + for record, texts in zip(entities.values(), layout.texts): + colour = self.app.colours.colour(self.row_tone(record)) + style = Style(background=colour, foreground=colour.get_contrast_text()) + rows.append(tuple(Content(text).stylize(style) for text in texts)) + columns_changed = tuple((column.key.value, column.width) for column in self.ordered_columns) != tuple( + (column.name, width) for column, width in zip(layout.columns, widths) + ) + same_rows = (tuple(self.entity_keys) == tuple(entities) + and tuple(row.key for row in self.ordered_rows) == tuple(self.entity_keys.values()) + and tuple(tuple(self.get_row(key)) for key in self.entity_keys.values()) == tuple(rows)) + with self.prevent(DataTable.RowHighlighted): + if same_rows and not columns_changed: + # The actual record can change while every painted cell stays equal. + self.records = {self.entity_keys[entity]: SelectedTableRecord(entity, record) + for entity, record in entities.items()} + else: + # Fresh anonymous keys come from add_row; entity_keys retains the + # semantic identity that restores selection after the native rebuild. + self.clear(columns=columns_changed) + if columns_changed: + for column, width in zip(layout.columns, widths): + self.add_column(column.label, key=column.name, width=width) + records: dict[RowKey, SelectedTableRecord[R]] = {} + keys: dict[DisclosureKey, RowKey] = {} + for (entity, record), cells in zip(entities.items(), rows): + native = self.add_row(*cells, height=None) + records[native], keys[entity] = SelectedTableRecord(entity, record), native + self.records, self.entity_keys = records, keys + self.scroll_to(x=offset.x, y=offset.y, animate=False, force=True) + selected.place_cursor(self) + if self.selected != previous: + self.selection_changed() + + def on_resize(self) -> None: + self.call_after_refresh(self.reconcile_source) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + event.stop() + self.selection_highlighted() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + event.stop() + self.selection_highlighted() + + def cursor_up(self) -> None: + DataTable.action_cursor_up(self) + + def cursor_down(self) -> None: + DataTable.action_cursor_down(self) + + def open_selected(self) -> None: + self.action_select_cursor() diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index 1415a6faf..d52337048 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -1,34 +1,57 @@ -"""Textual-facing: the commander page paints the HUD change Core's stream delivers.""" +"""Textual-facing: retain the real commander page across Core frame replacements.""" import json from dataclasses import replace from agent_comms.commander.vibe_check import OverloadDegradation from agent_comms.commander_hud import ( - CommanderFrame, CommanderHud, CommanderUnit, CodexUsage, UnreadableUsage, + CommanderFrame, CommanderHud, CommanderUnit, CodexUsage, HudRecord, UnreadableUsage, ) +from agent_comms.disclosure_text import DisclosureEntry, DisclosureText, FieldKey, RootKey, StructuredText +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.standing_rules import ServicePassFailing +from agent_comms.task_attachments.commander_pings import AlarmSection, NewAlarms, RulePing from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.ui_model.observation import ObserveCommanderHud -from agent_comms.ui_model.unit_state import Detached, PendingClock -from textual.widgets import Static -from textual.widgets._collapsible import CollapsibleTitle +from agent_comms.ui_model.unit_state import Detached, Moving, PendingClock +from support.workspace import Workspace +from textual.worker import Worker, WorkerState +from textual.widgets import Button, Static from toad.comms_root import FailedObservation, UnselectedRoute from toad.core.events import CommanderHudChanged -from toad.screens.commander import CommanderFrameView, CommanderPage, CommanderUnitCard +from toad.screens.commander import ( + CommanderAlarm, CommanderFrameView, CommanderGroup, CommanderPage, CommanderTable, +) +from toad.widgets.declared_disclosure import DeclaredDisclosure +from toad.widgets.declared_table import EmptyTableSelection, SelectedTableRecord + + +def selected(record: HudRecord) -> SelectedTableRecord[HudRecord]: + return SelectedTableRecord(DisclosureText.describe(record).sequence_key, record) + +def row_maps_cohere(table: CommanderTable) -> bool: + return (set(table.records) == set(table.entity_keys.values()) == {row.key for row in table.ordered_rows} + and all(table.records[native].key == entity for entity, native in table.entity_keys.items())) -async def test_commander_page_survives_unreadable_frames_and_recovers(workspace): + +def published_hud() -> CommanderHud: unit = CommanderUnit( RecordedThreadIncarnation("hud-worker", 2.0), Detached, PendingClock(1.0, 1.0), (), 1, (), (), ) - hud = CommanderHud( - RecordedThreadIncarnation("claude-code", 1.0), (unit,), (), - OverloadDegradation.from_orders((), 0, 0), - (RecordedThreadIncarnation("fork-hud", 2.0),), - CodexUsage(UnreadableUsage(reason="not requested")), + return CommanderHud( + commander=RecordedThreadIncarnation("claude-code", 1.0), units=(unit,), phases=(), + load=OverloadDegradation.from_orders((), 0, 0), + forks=(RecordedThreadIncarnation("fork-hud", 2.0),), + usage=CodexUsage(UnreadableUsage(reason="not requested")), open_pings=(), ) + + +async def test_commander_page_survives_unreadable_frames_and_recovers(workspace: Workspace) -> None: + hud = published_hud() + unit, = hud.units CommanderFrame.replace_all(workspace.comms.root, (hud,)) frame, = CommanderFrame.matching_in(workspace.comms.root) written = json.loads(frame.read_text()) @@ -51,41 +74,69 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace) assert not notice.display last_good = tuple(rows.children) view = rows.query_one(CommanderFrameView) - await ui.until( - lambda: bool(view.query(CommanderUnitCard)), - why="the published unit card to mount inside its commander frame", - ) - card = view.query_one(CommanderUnitCard) - assert card.collapsed - title = card.query_one(CollapsibleTitle) - title.focus() + await ui.until(lambda: bool(view.query(CommanderTable)), why="the grouped native table to mount") + table = view.query_one(CommanderTable) + await ui.until(lambda: table.selected == selected(unit), why="the actual unit to acquire a native row") + table.focus() await pilot.press("enter") - await ui.until(lambda: not card.collapsed, why="the unit card to expand by keyboard") await ui.until_shows(Detached.action) - # This published fixture has no registered unit. Its original-detail - # refusal must stay in the disclosure while observation keeps working. + # There is no registered unit behind this published fixture. Its real + # original-detail refusal must remain visible while observation recovers. await ui.until_shows("Original details unavailable:") assert str(view.heading.render()) == "Commander claude-code" - assert str(view.status.render()).startswith(str(hud.load)) - assert hud.usage.line() in str(view.footer.render()) - assert "forks: fork-hud" in str(view.footer.render()) + strip = str(view.status.render()) + assert "Load: 0" in strip and "Forks: 1" in strip + assert hud.usage.line() in strip + assert view.selected == view.details.selection == selected(unit) + + # Observe the native worker lifecycle, not a patched read method or fake reply. + events: list[Worker.StateChanged] = [] + view.details.listen(Worker.StateChanged, events.append) + original_source = table.source + original_keys = tuple(table.entity_keys.values()) + identical = replace(hud, units=(replace(unit, clock=PendingClock(1.0, 2.0)),)) + view.present(identical) + await ui.step() + # A genuinely fresh but equal frozen table must not re-prepare native identities. + group = table.query_ancestor(CommanderGroup) + equivalent = replace(group.section, tables=tuple(replace(source) for source in group.section.tables)) + assert equivalent.tables[0] == original_source and equivalent.tables[0] is not original_source + group.present(equivalent) + group.render_section() + await ui.step() + assert table.source is original_source + assert tuple(table.entity_keys.values()) == original_keys + assert table.selected == selected(unit) and row_maps_cohere(table) + assert not [event for event in events if event.state is WorkerState.PENDING] frame.write_text(unreadable) await ui.until(lambda: notice.display, why="the next unreadable frame to reach the page") assert "field_from_a_newer_build" in str(notice.render()) assert tuple(rows.children) == last_good + assert table.has_focus repaired = replace( hud, units=(replace(unit, strikes=2),), forks=(RecordedThreadIncarnation("recovered", 3.0),), ) CommanderFrame.replace_all(workspace.comms.root, (repaired,)) - await ui.until_shows("forks: recovered") + await ui.until(lambda: view.details.selection == selected(repaired.units[0]), + why="changed strikes to invalidate the real selected card") + await ui.until(lambda: len([event for event in events if event.state is WorkerState.PENDING]) == 1, + why="one new original-detail worker for the changed card") + await ui.until_shows("Original details unavailable:") assert not notice.display assert rows.query_one(CommanderFrameView) is view - assert view.query_one(CommanderUnitCard) is card - assert not card.collapsed and title.has_focus - assert card.card.strikes == 2 + assert view.query_one(CommanderTable) is table and table.has_focus + assert table.selected == selected(repaired.units[0]) + assert table.source != original_source + assert tuple(table.entity_keys.values()) == original_keys and row_maps_cohere(table) + assert len(view.hud.forks) == 1 and view.hud.forks[0].name == "recovered" + assert "Forks: 1" in str(view.status.render()) + # Explicit refresh is unconditional, even for an otherwise identical card. + view.details.query_one(Button).press() + await ui.until(lambda: len([event for event in events if event.state is WorkerState.PENDING]) == 2, + why="explicit Refresh to launch another bounded read") recovered_rows = tuple(rows.children) failed = FailedObservation( OSError("observer process exited"), UnselectedRoute(), @@ -100,7 +151,7 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace) assert app.screen is not page -async def test_a_heartbeat_with_no_frames_still_says_it_is_waiting(workspace): +async def test_a_heartbeat_with_no_frames_still_says_it_is_waiting(workspace: Workspace) -> None: async with workspace.app() as ui: app, _pilot = ui.textual page = CommanderPage() @@ -110,3 +161,117 @@ async def test_a_heartbeat_with_no_frames_still_says_it_is_waiting(workspace): await ui.step() rows = page.query_one("#hud-rows").query(Static) assert [str(row.render()) for row in rows] == ["Waiting for the commander heartbeat."] + + +async def test_selection_survives_filter_clear_refill_group_move_and_resize(workspace: Workspace) -> None: + hud = published_hud() + first, = hud.units + second = replace(first, unit=RecordedThreadIncarnation("second-worker", 3.0)) + hud = replace(hud, units=(first, second)) + CommanderFrame.replace_all(workspace.comms.root, (hud,)) + async with workspace.app() as ui: + app, _pilot = ui.textual + page = CommanderPage() + await app.push_screen(page) + await ui.until(lambda: bool(page.query(CommanderTable)), why="the native table to mount") + view = page.query_one(CommanderFrameView) + table = view.query_one(CommanderTable) + await ui.until(lambda: table.row_count == 2, why="both original records to acquire native rows") + table.focus() + table.move_cursor(row=1, animate=False) + await ui.until(lambda: view.selected == selected(second), why="the highlighted second record") + semantic = view.selected.key + before_keys = tuple(table.entity_keys.values()) + assert set(table.records) == set(before_keys) and row_maps_cohere(table) + + await ui.resize(60, 40) + await ui.until(lambda: not table.show_header, why="the narrow stacked layout") + assert table.selected.key == semantic + assert table.selected == selected(second) + assert row_maps_cohere(table) + assert tuple(table.entity_keys.values()) != before_keys # Anonymous keys came back from add_row. + await ui.resize(160, 40) + await ui.until(lambda: table.show_header, why="the wide table layout") + assert table.selected.key == semantic + + view.filter.value = "nothing-matches-this-record" + await ui.until(lambda: view.selected == EmptyTableSelection(), why="filter-all empty selection") + await ui.until(lambda: not view.details.original.display, why="old details to hide") + assert view.details.selection == EmptyTableSelection() + assert str(view.details.status.render()) == "No records match the filter." + await ui.until(lambda: all(native.row_count == 0 for native in view.query(CommanderTable)), + why="the native rows to clear too") + assert all(not native.records and not native.entity_keys for native in view.query(CommanderTable)) + view.filter.value = "" + await ui.until(lambda: view.selected == selected(first), why="clear-and-refill to choose the first visible record") + await ui.until(lambda: table.row_count == 2, why="the public native rows to refill") + table.focus() + table.move_cursor(row=1, animate=False) + await ui.until(lambda: view.selected == selected(second), why="the second semantic identity to be selected again") + moved = replace(second, state=Moving) + CommanderFrame.replace_all(workspace.comms.root, (replace(hud, units=(first, moved)),)) + await ui.until(lambda: view.selected == selected(moved), why="the original selection to move between groups") + await ui.until(lambda: any(native.selected == selected(moved) for native in view.query(CommanderTable)), + why="the target group to resolve the same semantic key") + assert view.selected.key == semantic + await ui.until(lambda: any(native.has_focus and native.selected == selected(moved) + for native in view.query(CommanderTable)), + why="native focus to follow its selected semantic record") + assert view.details.selection == selected(moved) + assert all(row_maps_cohere(native) for native in view.query(CommanderTable)) + CommanderFrame.replace_all(workspace.comms.root, (replace(hud, units=()),)) + await ui.until(lambda: view.selected == EmptyTableSelection(), why="an actually empty frame") + CommanderFrame.replace_all(workspace.comms.root, (hud,)) + await ui.until(lambda: view.selected == selected(first), why="a later frame to refill the original records") + + +async def test_core_summaries_and_alarm_ages_reach_native_disclosures(workspace: Workspace) -> None: + hud = published_hud() + async with workspace.app() as ui: + app, _pilot = ui.textual + for value in (hud.units[0].strikes, CommanderUnit, Detached): + disclosed = DisclosureText.describe(value) + disclosure = DeclaredDisclosure(DisclosureEntry(RootKey(type(value)), "Original", disclosed)) + await app.screen.mount(disclosure) + disclosure.collapsed = False + await ui.until(lambda: disclosure.body.display, why="the scalar/class/family body to expand") + assert str(disclosure.body.render()) == disclosed.summary + assert disclosed.summary in str(disclosure.title) + assert DisclosureText.describe(Detached).summary == Detached.declared_name + assert not str(disclosure.body.render()).startswith("