From a81972a21a5ed535d85b2a98a77c5ac1ad347192 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:14:32 -0400 Subject: [PATCH 01/15] Replace commander cards with a declaration-driven keyed table --- src/toad/screens/commander.py | 248 ++++++++++++------------ src/toad/widgets/declared_disclosure.py | 121 ++++++------ src/toad/widgets/declared_table.py | 138 +++++++++++++ 3 files changed, 325 insertions(+), 182 deletions(-) create mode 100644 src/toad/widgets/declared_table.py diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index cf46144e6..5258477c6 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -1,4 +1,4 @@ -"""Expandable views of the commander's published heartbeat frames.""" +"""A declaration-driven table over the commander's published heartbeat frames.""" from __future__ import annotations @@ -11,28 +11,34 @@ from agent_comms.errors import RelationViolationError, RouteChangedError, ToolRefusedError from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.observation import ReadCommanderUnit, UnreadableHistory +from agent_comms.ui_model.unit_state import ( + AwaitingApplicationRun, Blocked, Idle, Moving, NeedsCommanderAttention, UnitState, Unreadable, +) 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.widgets import Button, Checkbox, Collapsible, DataTable, 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_table import DeclaredTable +from toad.widgets.sidebar_tree import KeyedRows class HudRowText(MroDispatch): - """Core's ordinary rows; units and board entries have their own disclosures.""" + """Core's ordinary rows; units and board entries have their own views.""" def lines(self, row: HudRow) -> tuple[str, ...]: return type(self).handler_for(type(row))(self, row) @@ -46,77 +52,111 @@ def disclosed(self, row: HudRow) -> tuple[str, ...]: return () -class CommanderUnitCard(ToadNode, Collapsible): - """Retain the unit's disclosure while Core replaces its current card.""" +class UnitTone(MroDispatch): + """Core's nominal state family supplies the row's tone through its MRO.""" + + @classmethod + def of(cls, state: type[UnitState]) -> type[ThemeColour]: + return cls.handler_for(state)(state) + + @handles(NeedsCommanderAttention) + @staticmethod + def attention(state: type[NeedsCommanderAttention]) -> type[ThemeColour]: + return WarningColour + + @handles(Blocked, Unreadable) + @staticmethod + def blocked(state: type[UnitState]) -> type[ThemeColour]: + return ErrorColour + + @handles(AwaitingApplicationRun) + @staticmethod + def application(state: type[AwaitingApplicationRun]) -> type[ThemeColour]: + return AccentColour + + @handles(Moving) + @staticmethod + def moving(state: type[Moving]) -> type[ThemeColour]: + return SuccessColour + + @handles(Idle, UnitState) + @staticmethod + def idle(state: type[UnitState]) -> type[ThemeColour]: + return ForegroundMutedColour + + +class CommanderTable(DeclaredTable[CommanderUnit], record=CommanderUnit, view="hud"): + def row_tone(self, record: CommanderUnit) -> type[ThemeColour]: + return UnitTone.of(record.state) + + +class CommanderFrameView(ToadNode, VerticalGroup): + """One current frame, one native table selection, one lazy original-detail pane.""" 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; } + 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 .detail-status { color: $text-muted; } + CommanderFrameView .refresh-unit { width: auto; margin: 0; } """ - def __init__(self, commander: ThreadIncarnation, card: CommanderUnit) -> 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.", + row_text = HudRowText() + + def __init__(self, hud: CommanderHud) -> None: + self.hud = hud + self.heading = Static("", markup=False, classes="hud-commander") + self.status = Static("", markup=False, classes="hud-load") + self.table = CommanderTable() + self.filter = Input(placeholder="Filter units, state, work or changes") + self.needs_hand = Checkbox("Needs hand") + self.detail_status = Static("Highlight a unit to read its original records.", 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.footer = Static("", markup=False) + self.board_text = Static("", markup=False) + self.board = Collapsible(self.board_text, title="", collapsed=True) + super().__init__() + + def compose(self) -> ComposeResult: + yield self.heading + yield self.status + yield self.board + with HorizontalGroup(classes="hud-filter"): + yield self.filter + yield self.needs_hand + yield self.table + with VerticalGroup(classes="hud-detail"): + yield self.detail_status + yield Button("Refresh original details", classes="refresh-unit", compact=True) + yield self.original_details + yield self.footer + + def on_mount(self) -> None: + self.present(self.hud) - def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: - if event.collapsible is self: - event.stop() + @on(Input.Changed) + @on(Checkbox.Changed) + def filter_changed(self) -> None: + self.filter_units() + + def filter_units(self) -> None: + text = self.filter.value.casefold() + selected = self.table.selected + self.table.present( + card for card in self.hud.units + if (not self.needs_hand.value or card.needs_hand()) + and text in "\n".join(cell.plain for cell in self.table.cells(card)).casefold() + ) + if self.table.selected != selected: self.read_detail() - def on_collapsible_collapsed(self, event: Collapsible.Collapsed) -> None: - if event.collapsible is self: - event.stop() - self.workers.cancel_group(self, "commander-detail") + @on(DataTable.RowHighlighted) + @on(DataTable.RowSelected) + def highlight_unit(self) -> None: + self.read_detail() @on(Button.Pressed, ".refresh-unit") def refresh_detail(self, event: Button.Pressed) -> None: @@ -125,13 +165,17 @@ def refresh_detail(self, event: Button.Pressed) -> None: @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…") + card = self.table.selected + self.original_details.display = False + if card is None: + self.detail_status.update("No units match the filter.") + return + self.detail_status.update(f"Reading original orders and activity for {card.unit.name}…") access = self.app.coordination_access try: observed = await access.preparation.run_thread(access.observation.observed) request = ReadCommanderUnit( - str(observed.service.root), self.commander, card.unit, + str(observed.service.root), self.hud.commander, card.unit, tuple(dict.fromkeys(order.order for order in card.orders)), ) detail = await observed.process.read(request) @@ -139,10 +183,10 @@ async def read_detail(self) -> None: DeferredRead, RelationViolationError, RouteChangedError, ToolRefusedError, UnreadableHistory, WorkBranchError, ) as error: - if self.is_attached and self.card == card and not self.collapsed: + if self.is_attached and self.table.selected == card: self.detail_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.table.selected != card: return entry = DisclosureEntry( RootKey(type(detail)), "Original details", DisclosedValue.of(detail), @@ -152,56 +196,8 @@ async def read_detail(self) -> None: break else: self.original_details.mount(DeclaredDisclosure(entry)) - self.detail_status.update("Original records read; activity uses Core's bounded view.") - - -class CommanderFrameView(KeyedRows[ThreadIncarnation], VerticalGroup): - """The existing keyed-row capability owns unit retention and ordering.""" - - row_text = HudRowText() - - def __init__(self, hud: CommanderHud) -> None: - self.hud = hud - 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) - super().__init__() - - @property - def member_container(self) -> VerticalGroup: - return self.units - - @property - def rows(self) -> dict[ThreadIncarnation, CommanderUnitCard]: - return {row.card.unit: row for row in self.units.query_children(CommanderUnitCard)} - - @property - def cards(self) -> dict[ThreadIncarnation, CommanderUnit]: - return {card.unit: card for card in self.hud.units} - - def create_row(self, key: ThreadIncarnation) -> CommanderUnitCard: - return CommanderUnitCard(self.hud.commander, self.cards[key]) - - def update_row(self, key: ThreadIncarnation, row: CommanderUnitCard) -> None: - row.present(self.cards[key]) - - 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)) + self.original_details.display = True + self.detail_status.update(f"Original records for {card.unit.name}; activity is bounded by Core.") def present(self, hud: CommanderHud) -> None: self.hud = hud @@ -216,13 +212,13 @@ def present(self, hud: CommanderHud) -> None: 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.filter_units() class CommanderPage( KeyedRows[ThreadIncarnation], CoreEventReceiver, DismissibleScreen[None], ): - """Current Core frames preserve disclosures, focus and the underlying conversation.""" + """Current Core frames preserve table selection, focus and the conversation below.""" DEFAULT_CSS = """ CommanderPage { align: center middle; background: $background 40%; } diff --git a/src/toad/widgets/declared_disclosure.py b/src/toad/widgets/declared_disclosure.py index 13b7de523..8c9fdb072 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -2,12 +2,15 @@ from __future__ import annotations -from collections.abc import Collection, Mapping, Sequence -from dataclasses import dataclass, is_dataclass +from dataclasses import dataclass from functools import singledispatchmethod -from typing import ClassVar, Hashable +from typing import Hashable -from agent_comms.field_codec import DisclosureIdentity, FieldCodec, FieldPlan +from agent_comms.disclosure_text import ( + ClassText, DisclosureText, FamilyText, IdentifiedRecordText, + MappingText, RecordText, SequenceText, +) +from agent_comms.field_codec import FieldCodec from python_introspect import declared_field_label from textual import events from textual.containers import VerticalGroup @@ -53,41 +56,51 @@ class MappingKey(DisclosureKey): class DisclosedValue: """One borrowed value. Shape is recovered only at this structural boundary.""" - value: object + source: DisclosureText + + @property + def value(self) -> object: + return self.source.value - @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) + return cls.from_text(DisclosureText.of(value)) + + @singledispatchmethod + @classmethod + def from_text(cls, source: DisclosureText) -> DisclosedValue: + """Adapt Core's decoded shape; never rediscover the original value's shape.""" + return cls(source) + + @from_text.register(ClassText) + @classmethod + def _class(cls, source: ClassText) -> DisclosedValue: + return ClassValue(source) + + @from_text.register(FamilyText) + @classmethod + def _family_class(cls, source: FamilyText) -> DisclosedValue: + return FamilyClassValue(source) - @of.register(type) + @from_text.register(RecordText) @classmethod - def _class(cls, value: type) -> DisclosedValue: - return ClassValue(value) + def _record(cls, source: RecordText) -> DisclosedValue: + return RecordValue(source) - @of.register(Mapping) + @from_text.register(IdentifiedRecordText) @classmethod - def _mapping(cls, value: Mapping[Hashable, object]) -> DisclosedValue: - return MappingValue(value) + def _identified(cls, source: IdentifiedRecordText) -> DisclosedValue: + return IdentifiedRecordValue(source) - @of.register(Sequence) + @from_text.register(MappingText) @classmethod - def _sequence(cls, value: Sequence[object]) -> DisclosedValue: - return SequenceValue(value) + def _mapping(cls, source: MappingText) -> DisclosedValue: + return MappingValue(source) - @of.register(str) - @of.register(bytes) + @from_text.register(SequenceText) @classmethod - def _scalar(cls, value: str | bytes) -> DisclosedValue: - return cls(value) + def _sequence(cls, source: SequenceText) -> DisclosedValue: + return SequenceValue(source) def sequence_key(self) -> DisclosureKey: return AnonymousKey() @@ -95,9 +108,11 @@ def sequence_key(self) -> DisclosureKey: 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 summary(self, width: int | None = None) -> str: + return self.source.summary(width) + + def cell(self) -> Content: + return Content(str(self.value)) def show(self, view: DeclaredDisclosure) -> None: view.body.display = True @@ -107,13 +122,21 @@ def show(self, view: DeclaredDisclosure) -> None: @dataclass(frozen=True) class ClassValue(DisclosedValue): - value: type + source: ClassText + + def cell(self) -> Content: + return Content(self.summary()) - def summary(self, width: int) -> str: - return self.value.__name__ + +@dataclass(frozen=True) +class FamilyClassValue(ClassValue): + source: FamilyText class StructuredValue(DisclosedValue): + def cell(self) -> Content: + return Content(self.summary()) + def children(self) -> tuple[DisclosureEntry, ...]: raise NotImplementedError @@ -125,9 +148,6 @@ def show(self, view: DeclaredDisclosure) -> None: class RecordValue(StructuredValue): - def summary(self, width: int) -> str: - return type(self.value).__name__ - def children(self) -> tuple[DisclosureEntry, ...]: return tuple( DisclosureEntry( @@ -141,12 +161,12 @@ def children(self) -> tuple[DisclosureEntry, ...]: @dataclass(frozen=True) class IdentifiedRecordValue(RecordValue): - identity: tuple[FieldPlan, ...] + source: IdentifiedRecordText def sequence_key(self) -> EntityKey: return EntityKey(type(self.value), tuple( DisclosedValue.of(plan.current(self.value)).identity_part() - for plan in self.identity + for plan in self.source.identity )) def identity_part(self) -> EntityKey: @@ -154,36 +174,25 @@ def identity_part(self) -> EntityKey: @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] +class SequenceValue(StructuredValue): + source: SequenceText def children(self) -> tuple[DisclosureEntry, ...]: entries = [] - for index, value in enumerate(self.value): + for index, value in enumerate(self.source.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" +class MappingValue(StructuredValue): + source: MappingText def children(self) -> tuple[DisclosureEntry, ...]: return tuple( DisclosureEntry(MappingKey(key), str(key), DisclosedValue.of(value)) - for key, value in self.value.items() + for key, value in self.source.value.items() ) diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py new file mode 100644 index 000000000..6eda789a1 --- /dev/null +++ b/src/toad/widgets/declared_table.py @@ -0,0 +1,138 @@ +"""A keyed table whose columns and cells come from record declarations.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import get_type_hints + +from agent_comms.field_codec import FieldCodec +from formbridge.forms.ui_utils import FieldDisplayText +from python_introspect import declared_field_label +from rich.cells import cell_len +from rich.style import Style +from textual._two_way_dict import TwoWayDict +from textual.content import Content +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 +from toad.widgets.declared_disclosure import DisclosureKey, DisclosedValue + + +class DeclaredTable[R](MovesCursor, OpensSelected, DataTable[Content]): + """Keep native rows and cursor identity while the source replaces its records.""" + + record: type[R] + view: str + + def __init_subclass__(cls, *, record: type[R], view: str, **kwargs) -> None: + cls.record = record + cls.view = view + super().__init_subclass__(**kwargs) + + def __init__(self, **kwargs) -> None: + super().__init__(cursor_type="row", show_row_labels=False, **kwargs) + plans = tuple(plan for plan in FieldCodec.field_plans(self.record) + if plan.records and not plan.excluded_in(self.view)) + projections = FieldCodec.projections(self.record, self.view) + self.readers: dict[str, Callable[[R], object]] = { + plan.name: plan.current for plan in plans + } | {name: member.fget for name, member in projections.items()} + annotations = {plan.name: plan.annotation for plan in plans} | { + name: get_type_hints(member.fget, include_extras=True)["return"] + for name, member in projections.items() + } + for name, annotation in annotations.items(): + label = declared_field_label(annotation) or FieldDisplayText.from_field_name(name).display_name + self.add_column(label, key=name) + self.records: dict[RowKey, R] = {} + self.entity_keys: dict[DisclosureKey, RowKey] = {} + + def row_tone(self, record: R) -> type[ThemeColour]: + return ForegroundColour + + def cells(self, record: R) -> tuple[Content, ...]: + return tuple(DisclosedValue.of(read(record)).cell() for read in self.readers.values()) + + @property + def selected_key(self) -> RowKey | None: + if not self.row_count: + return None + return self.coordinate_to_cell_key(self.cursor_coordinate).row_key + + @property + def selected(self) -> R | None: + return self.records.get(self.selected_key) + + def present(self, records: Iterable[R]) -> None: + selected = self.selected_key + entities = {DisclosedValue.of(record).sequence_key(): record for record in records} + keys = {entity: self.entity_keys.get(entity, RowKey()) for entity in entities} + incoming = {keys[entity]: record for entity, record in entities.items()} + for key in self.records.keys() - incoming.keys(): + self.remove_row(key) + for key, record in incoming.items(): + if key in self.records and self.records[key] == record: + continue + colour = self.app.colours.colour(self.row_tone(record)) + style = Style(color=colour.rich_color) + cells = tuple(cell.stylize(style) for cell in self.cells(record)) + if key in self.rows: + for name, cell in zip(self.readers, cells): + self.update_cell(key, name, cell) + else: + self.add_row(*cells, key=key, height=None) + self.records = incoming + self.entity_keys = keys + # DataTable has no public keyed reorder. Its own sort() uses this same index; + # retain Core's supplied order without inventing another state sort in the UI. + self._row_locations = TwoWayDict({key: index for index, key in enumerate(incoming)}) + self.fit_columns() + if selected in incoming: + self.move_cursor(row=self.get_row_index(selected), animate=False) + elif incoming: + self.move_cursor(row=min(self.cursor_row, len(incoming) - 1), animate=False) + + def fit_columns(self) -> None: + columns = self.ordered_columns + if not columns: + return + weights = [] + for column in columns: + weight = max((cell_len(line) + for cell in self.get_column(column.key) + for line in cell.plain.splitlines()), default=0) + column.content_width = max(cell_len(column.label.plain), weight) + weights.append(max(1, column.content_width)) + available = max(len(columns), self.size.width - self.scrollbar_size_vertical + - self.cell_padding * 2 * len(columns)) + flexible = available - len(columns) + total = sum(weights) + used = 0 + for column, weight in zip(columns, weights): + end = used + weight + column.auto_width = False + column.width = 1 + flexible * end // total - flexible * used // total + used = end + # Auto-height is native DataTable behavior, but it must be invalidated when + # column widths or current cell text change, not only when a row is first added. + for row in self.rows.values(): + row.height = 0 + self._new_rows.update(self.rows) + self._require_update_dimensions = True + self._update_count += 1 + self._clear_caches() + self.refresh(layout=True) + + def on_resize(self) -> None: + self.fit_columns() + + 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() From 636b2e40208a666ea812cded44858e4459469b46 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:16:16 -0400 Subject: [PATCH 02/15] Keep decoded disclosure values and verify keyed commander refresh --- src/toad/widgets/declared_disclosure.py | 5 ++ src/toad/widgets/declared_table.py | 14 ++-- tests/textual/test_commander_page.py | 103 +++++++++++++++++------- 3 files changed, 88 insertions(+), 34 deletions(-) diff --git a/src/toad/widgets/declared_disclosure.py b/src/toad/widgets/declared_disclosure.py index 8c9fdb072..af0951b50 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -127,6 +127,11 @@ class ClassValue(DisclosedValue): def cell(self) -> Content: return Content(self.summary()) + def show(self, view: DeclaredDisclosure) -> None: + view.body.display = True + view.members.display = False + view.body.update(self.summary()) + @dataclass(frozen=True) class FamilyClassValue(ClassValue): diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 6eda789a1..61198520d 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -9,7 +9,7 @@ from formbridge.forms.ui_utils import FieldDisplayText from python_introspect import declared_field_label from rich.cells import cell_len -from rich.style import Style +from textual.style import Style from textual._two_way_dict import TwoWayDict from textual.content import Content from textual.widgets import DataTable @@ -39,16 +39,18 @@ def __init__(self, **kwargs) -> None: self.readers: dict[str, Callable[[R], object]] = { plan.name: plan.current for plan in plans } | {name: member.fget for name, member in projections.items()} - annotations = {plan.name: plan.annotation for plan in plans} | { + self.annotations = {plan.name: plan.annotation for plan in plans} | { name: get_type_hints(member.fget, include_extras=True)["return"] for name, member in projections.items() } - for name, annotation in annotations.items(): - label = declared_field_label(annotation) or FieldDisplayText.from_field_name(name).display_name - self.add_column(label, key=name) self.records: dict[RowKey, R] = {} self.entity_keys: dict[DisclosureKey, RowKey] = {} + def on_mount(self) -> None: + for name, annotation in self.annotations.items(): + label = declared_field_label(annotation) or FieldDisplayText.from_field_name(name).display_name + self.add_column(label, key=name) + def row_tone(self, record: R) -> type[ThemeColour]: return ForegroundColour @@ -76,7 +78,7 @@ def present(self, records: Iterable[R]) -> None: if key in self.records and self.records[key] == record: continue colour = self.app.colours.colour(self.row_tone(record)) - style = Style(color=colour.rich_color) + style = Style(background=colour, foreground=colour.get_contrast_text()) cells = tuple(cell.stylize(style) for cell in self.cells(record)) if key in self.rows: for name, cell in zip(self.readers, cells): diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index 1415a6faf..2f96adbd4 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -1,32 +1,47 @@ -"""Textual-facing: the commander page paints the HUD change Core's stream delivers.""" +"""Textual-facing: declared commander rows survive refresh and source failures.""" 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, CommanderOrder, CommanderUnit, CodexUsage, UnreadableUsage, ) +from agent_comms.field_codec import FieldCodec, OrderId from agent_comms.thread_identity import RecordedThreadIncarnation +from agent_comms.todos import NoCommitProgress from agent_comms.ui_model.observation import ObserveCommanderHud -from agent_comms.ui_model.unit_state import Detached, PendingClock +from agent_comms.ui_model.unit_state import ( + AwaitingApplicationRun, Blocked, Detached, Idle, Moving, PendingClock, UnitState, Unreadable, +) from textual.widgets import Static -from textual.widgets._collapsible import CollapsibleTitle +from ui_theme.scheme import ( + AccentColour, ErrorColour, ForegroundMutedColour, SuccessColour, WarningColour, +) 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 CommanderFrameView, CommanderPage, CommanderTable, UnitTone async def test_commander_page_survives_unreadable_frames_and_recovers(workspace): - unit = CommanderUnit( - RecordedThreadIncarnation("hud-worker", 2.0), Detached, - PendingClock(1.0, 1.0), (), 1, (), (), + order = CommanderOrder( + "P927", "Make the commander readable", OrderId("123456789abc"), + "pending", NoCommitProgress(), (), 0, 0, + ) + units = tuple( + CommanderUnit( + RecordedThreadIncarnation(f"hud-worker-{state.declared_name}", float(index + 2)), + state, PendingClock(1.0, 1.0), + (order, replace(order, problem="P928", order=OrderId("23456789abcd"))), + 1, (), (), + ) + for index, state in enumerate(UnitState.members_with(UnitState)) ) hud = CommanderHud( - RecordedThreadIncarnation("claude-code", 1.0), (unit,), (), + RecordedThreadIncarnation("claude-code", 1.0), units, (), OverloadDegradation.from_orders((), 0, 0), - (RecordedThreadIncarnation("fork-hud", 2.0),), + (RecordedThreadIncarnation("fork-hud", 200.0),), CodexUsage(UnreadableUsage(reason="not requested")), ) CommanderFrame.replace_all(workspace.comms.root, (hud,)) @@ -48,27 +63,47 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace) CommanderFrame.replace_all(workspace.comms.root, (hud,)) await ui.until_shows("Commander claude-code") + view = rows.query_one(CommanderFrameView) + table = view.query_one(CommanderTable) + await ui.until(lambda: table.row_count == len(units), why="the declared table to receive units") 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", + names = tuple(plan.name for plan in FieldCodec.field_plans(CommanderUnit) + if plan.records and not plan.excluded_in("hud")) + assert tuple(key.value for key in table.columns) == ( + *names, *FieldCodec.projections(CommanderUnit, "hud"), ) - card = view.query_one(CommanderUnitCard) - assert card.collapsed - title = card.query_one(CollapsibleTitle) - title.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. + assert [column.label.plain for column in table.ordered_columns] == [ + "Unit", "State", "Work", "Change", "Next", + ] + assert UnitTone.of(Blocked) is ErrorColour + assert UnitTone.of(Unreadable) is ErrorColour + assert UnitTone.of(Detached) is WarningColour + assert UnitTone.of(AwaitingApplicationRun) is AccentColour + assert UnitTone.of(Moving) is SuccessColour + assert UnitTone.of(Idle) is ForegroundMutedColour + for key, card in table.records.items(): + assert table.get_cell(key, "unit").plain == card.unit.name + state = table.get_cell(key, "state") + assert state.plain == card.state.declared_name + assert state.spans[-1].style.background == app.colours.colour(UnitTone.of(card.state)) + assert table.get_cell(key, "work").plain == "P927 pending\nP928 pending" + assert table.rows[key].height > 1 + moving = next(key for key, card in table.records.items() if card.state is Moving) + table.move_cursor(row=table.get_row_index(moving)) + table.focus() + await ui.step() await ui.until_shows("Original details unavailable:") + assert table.selected_key == moving + retained_row = table.rows[moving] 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()) + await pilot.resize_terminal(80, 40) + await ui.step() + assert sum(column.get_render_width(table) for column in table.ordered_columns) <= table.size.width + assert table.selected_key == moving frame.write_text(unreadable) await ui.until(lambda: notice.display, why="the next unreadable frame to reach the page") @@ -76,16 +111,28 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace) assert tuple(rows.children) == last_good repaired = replace( - hud, units=(replace(unit, strikes=2),), - forks=(RecordedThreadIncarnation("recovered", 3.0),), + hud, units=tuple(replace(unit, strikes=2) for unit in reversed(units)), + forks=(RecordedThreadIncarnation("recovered", 201.0),), ) CommanderFrame.replace_all(workspace.comms.root, (repaired,)) await ui.until_shows("forks: recovered") 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 + assert table.selected_key == moving and table.has_focus + assert table.rows[moving] is retained_row + assert table.selected.strikes == 2 + assert tuple(card.unit for card in table.records.values()) == tuple( + card.unit for card in repaired.units + ) + view.filter.value = "hud-worker-moving" + await ui.until(lambda: table.row_count == 1, why="the cell-text filter") + assert table.selected_key == moving + view.needs_hand.value = True + await ui.until(lambda: table.row_count == 0, why="the needs-hand filter to exclude moving") + view.filter.value = "" + await ui.until(lambda: table.row_count == sum(card.needs_hand() for card in repaired.units), + why="the needs-hand filter to use Core's answer") recovered_rows = tuple(rows.children) failed = FailedObservation( OSError("observer process exited"), UnselectedRoute(), From d365ab49b1f8e2379af9fe1feb101ced3f7d4e5d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:17:29 -0400 Subject: [PATCH 03/15] Fill commander rows at the frame mount event --- src/toad/screens/commander.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 5258477c6..a2890a898 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -136,6 +136,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.present(self.hud) + self.filter_units() @on(Input.Changed) @on(Checkbox.Changed) From 8e9a69d988db590354a8c13cdd5447a4f9838d7b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:20:43 -0400 Subject: [PATCH 04/15] Bound the commander viewport and reflow only changed table dimensions --- src/toad/screens/commander.py | 4 ++-- src/toad/widgets/declared_table.py | 30 ++++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index a2890a898..42f51c2a2 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -224,11 +224,11 @@ class CommanderPage( 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; } diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 61198520d..7f9f89352 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -74,6 +74,7 @@ def present(self, records: Iterable[R]) -> None: incoming = {keys[entity]: record for entity, record in entities.items()} for key in self.records.keys() - incoming.keys(): self.remove_row(key) + changed: set[RowKey] = set() for key, record in incoming.items(): if key in self.records and self.records[key] == record: continue @@ -85,11 +86,14 @@ def present(self, records: Iterable[R]) -> None: self.update_cell(key, name, cell) else: self.add_row(*cells, key=key, height=None) + changed.add(key) self.records = incoming self.entity_keys = keys # DataTable has no public keyed reorder. Its own sort() uses this same index; # retain Core's supplied order without inventing another state sort in the UI. self._row_locations = TwoWayDict({key: index for index, key in enumerate(incoming)}) + self._update_count += 1 + self.reflow_rows(changed) self.fit_columns() if selected in incoming: self.move_cursor(row=self.get_row_index(selected), animate=False) @@ -112,16 +116,26 @@ def fit_columns(self) -> None: flexible = available - len(columns) total = sum(weights) used = 0 - for column, weight in zip(columns, weights): + widths = [] + for weight in weights: end = used + weight - column.auto_width = False - column.width = 1 + flexible * end // total - flexible * used // total + widths.append(1 + flexible * end // total - flexible * used // total) used = end - # Auto-height is native DataTable behavior, but it must be invalidated when - # column widths or current cell text change, not only when a row is first added. - for row in self.rows.values(): - row.height = 0 - self._new_rows.update(self.rows) + if widths == [column.width for column in columns] and not any( + column.auto_width for column in columns + ): + return + for column, width in zip(columns, widths): + column.auto_width = False + column.width = width + self.reflow_rows(self.rows) + + def reflow_rows(self, keys: Iterable[RowKey]) -> None: + # Re-measure native auto-height only after cell text or column widths change. + # A height-only Resize must not zero the rows again: that feeds its own layout. + for key in keys: + self.rows[key].height = 0 + self._new_rows.add(key) self._require_update_dimensions = True self._update_count += 1 self._clear_caches() From 8d92997c235700669932907f2ca5b4a26d7a59a4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:19:24 -0400 Subject: [PATCH 05/15] Consume shared HUD columns and family groups in commander page --- src/toad/screens/commander.py | 50 ++++++++++++++++++---------- src/toad/widgets/declared_table.py | 50 +++++++++++----------------- tests/textual/test_commander_page.py | 28 ++++++++++------ 3 files changed, 69 insertions(+), 59 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 42f51c2a2..085b31834 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -12,7 +12,8 @@ from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.observation import ReadCommanderUnit, UnreadableHistory from agent_comms.ui_model.unit_state import ( - AwaitingApplicationRun, Blocked, Idle, Moving, NeedsCommanderAttention, UnitState, Unreadable, + Blocked, HudGroup, IdleGroup, InProgressGroup, ReadyToTestGroup, + UnitState, Unreadable, UrgentGroup, ) from agent_comms.work_branches import WorkBranchError from metaclass_registry.mro_dispatch import MroDispatch, handles @@ -20,6 +21,7 @@ from textual.app import ComposeResult from textual.containers import HorizontalGroup, Vertical, VerticalGroup, VerticalScroll from textual.content import Content +from textual.style import Style from textual.widgets import Button, Checkbox, Collapsible, DataTable, Input, Static from ui_theme.scheme import ( AccentColour, ErrorColour, ForegroundMutedColour, SuccessColour, ThemeColour, WarningColour, @@ -56,12 +58,17 @@ class UnitTone(MroDispatch): """Core's nominal state family supplies the row's tone through its MRO.""" @classmethod - def of(cls, state: type[UnitState]) -> type[ThemeColour]: + def of(cls, state: type[UnitState | HudGroup]) -> type[ThemeColour]: return cls.handler_for(state)(state) - @handles(NeedsCommanderAttention) + @handles(UnitState) @staticmethod - def attention(state: type[NeedsCommanderAttention]) -> type[ThemeColour]: + def state(state: type[UnitState]) -> type[ThemeColour]: + return UnitTone.of(state.hud_group) + + @handles(UrgentGroup) + @staticmethod + def attention(group: type[UrgentGroup]) -> type[ThemeColour]: return WarningColour @handles(Blocked, Unreadable) @@ -69,19 +76,19 @@ def attention(state: type[NeedsCommanderAttention]) -> type[ThemeColour]: def blocked(state: type[UnitState]) -> type[ThemeColour]: return ErrorColour - @handles(AwaitingApplicationRun) + @handles(ReadyToTestGroup) @staticmethod - def application(state: type[AwaitingApplicationRun]) -> type[ThemeColour]: + def application(group: type[ReadyToTestGroup]) -> type[ThemeColour]: return AccentColour - @handles(Moving) + @handles(InProgressGroup) @staticmethod - def moving(state: type[Moving]) -> type[ThemeColour]: + def moving(group: type[InProgressGroup]) -> type[ThemeColour]: return SuccessColour - @handles(Idle, UnitState) + @handles(IdleGroup) @staticmethod - def idle(state: type[UnitState]) -> type[ThemeColour]: + def idle(group: type[IdleGroup]) -> type[ThemeColour]: return ForegroundMutedColour @@ -109,6 +116,7 @@ def __init__(self, hud: CommanderHud) -> None: self.hud = hud self.heading = Static("", markup=False, classes="hud-commander") self.status = Static("", markup=False, classes="hud-load") + self.group_headings = Static("", markup=False, classes="hud-groups") self.table = CommanderTable() self.filter = Input(placeholder="Filter units, state, work or changes") self.needs_hand = Checkbox("Needs hand") @@ -123,6 +131,7 @@ def __init__(self, hud: CommanderHud) -> None: def compose(self) -> ComposeResult: yield self.heading yield self.status + yield self.group_headings yield self.board with HorizontalGroup(classes="hud-filter"): yield self.filter @@ -147,7 +156,7 @@ def filter_units(self) -> None: text = self.filter.value.casefold() selected = self.table.selected self.table.present( - card for card in self.hud.units + card for section in self.hud.groups() for card in section.units if (not self.needs_hand.value or card.needs_hand()) and text in "\n".join(cell.plain for cell in self.table.cells(card)).casefold() ) @@ -203,15 +212,20 @@ async def read_detail(self) -> None: 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()), - ))) + now = time.time() + self.status.update(hud.status_strip(now)) + self.group_headings.update(Content("\n").join( + Content(f"{section.group.heading()} · {len(section.units)}").stylize( + Style(foreground=self.app.colours.colour(UnitTone.of(section.group)))) + for section in hud.groups() + )) 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) + count = sum(len(packet.handled) for packet in hud.packets) + self.board.title = Content(f"{count} original alarms") + self.board_text.update("\n".join(hud.alarm_detail(hud.packets, now))) + self.board.display = bool(count) if self.is_mounted: self.filter_units() @@ -232,7 +246,7 @@ class CommanderPage( CommanderPage CommanderFrameView { height: auto; } CommanderPage Static { height: auto; } CommanderPage .hud-commander { text-style: bold reverse; } - CommanderPage .hud-load { text-style: bold; } + CommanderPage .hud-load, CommanderPage .hud-groups { text-style: bold; } CommanderPage #commander-close { dock: bottom; margin: 0; } """ diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 7f9f89352..d66456169 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -2,12 +2,9 @@ from __future__ import annotations -from collections.abc import Callable, Iterable -from typing import get_type_hints +from collections.abc import Iterable -from agent_comms.field_codec import FieldCodec -from formbridge.forms.ui_utils import FieldDisplayText -from python_introspect import declared_field_label +from agent_comms.field_codec import FieldCodec, ViewColumn from rich.cells import cell_len from textual.style import Style from textual._two_way_dict import TwoWayDict @@ -33,29 +30,20 @@ def __init_subclass__(cls, *, record: type[R], view: str, **kwargs) -> None: def __init__(self, **kwargs) -> None: super().__init__(cursor_type="row", show_row_labels=False, **kwargs) - plans = tuple(plan for plan in FieldCodec.field_plans(self.record) - if plan.records and not plan.excluded_in(self.view)) - projections = FieldCodec.projections(self.record, self.view) - self.readers: dict[str, Callable[[R], object]] = { - plan.name: plan.current for plan in plans - } | {name: member.fget for name, member in projections.items()} - self.annotations = {plan.name: plan.annotation for plan in plans} | { - name: get_type_hints(member.fget, include_extras=True)["return"] - for name, member in projections.items() - } + self.column_plans = FieldCodec.view_columns(self.record, self.view) self.records: dict[RowKey, R] = {} self.entity_keys: dict[DisclosureKey, RowKey] = {} def on_mount(self) -> None: - for name, annotation in self.annotations.items(): - label = declared_field_label(annotation) or FieldDisplayText.from_field_name(name).display_name - self.add_column(label, key=name) + for column in self.column_plans: + self.add_column(column.label, key=column.name) + self.present(tuple(self.records.values())) def row_tone(self, record: R) -> type[ThemeColour]: return ForegroundColour def cells(self, record: R) -> tuple[Content, ...]: - return tuple(DisclosedValue.of(read(record)).cell() for read in self.readers.values()) + return tuple(DisclosedValue.of(column.current(record)).cell() for column in self.column_plans) @property def selected_key(self) -> RowKey | None: @@ -72,18 +60,23 @@ def present(self, records: Iterable[R]) -> None: entities = {DisclosedValue.of(record).sequence_key(): record for record in records} keys = {entity: self.entity_keys.get(entity, RowKey()) for entity in entities} incoming = {keys[entity]: record for entity, record in entities.items()} + # A parent's Mount can supply the frame before this table's Mount creates columns. + # Keep the supplied records; the table's own column owner admits their native rows. + if not self.columns: + self.records, self.entity_keys = incoming, keys + return for key in self.records.keys() - incoming.keys(): self.remove_row(key) changed: set[RowKey] = set() for key, record in incoming.items(): - if key in self.records and self.records[key] == record: + if key in self.rows and self.records[key] == record: continue colour = self.app.colours.colour(self.row_tone(record)) style = Style(background=colour, foreground=colour.get_contrast_text()) cells = tuple(cell.stylize(style) for cell in self.cells(record)) if key in self.rows: - for name, cell in zip(self.readers, cells): - self.update_cell(key, name, cell) + for column, cell in zip(self.column_plans, cells): + self.update_cell(key, column.name, cell) else: self.add_row(*cells, key=key, height=None) changed.add(key) @@ -113,15 +106,10 @@ def fit_columns(self) -> None: weights.append(max(1, column.content_width)) available = max(len(columns), self.size.width - self.scrollbar_size_vertical - self.cell_padding * 2 * len(columns)) - flexible = available - len(columns) - total = sum(weights) - used = 0 - widths = [] - for weight in weights: - end = used + weight - widths.append(1 + flexible * end // total - flexible * used // total) - used = end - if widths == [column.width for column in columns] and not any( + minimums = tuple(plan.minimum_width(cell.plain for cell in self.get_column(column.key)) + for plan, column in zip(self.column_plans, columns)) + widths = ViewColumn.fit_widths(weights, available, minimums) + if widths == tuple(column.width for column in columns) and not any( column.auto_width for column in columns ): return diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index 2f96adbd4..1ed2b1afa 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -62,20 +62,27 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace) ] CommanderFrame.replace_all(workspace.comms.root, (hud,)) + await ui.until( + lambda: bool(rows.query_children(CommanderFrameView)) + or any(notification.severity == "error" for notification in app._notifications), + why="the actual frame mount, or its original application error", + ) + errors = tuple(notification.message for notification in app._notifications + if notification.severity == "error") + assert not errors, "\n".join(errors) await ui.until_shows("Commander claude-code") view = rows.query_one(CommanderFrameView) table = view.query_one(CommanderTable) await ui.until(lambda: table.row_count == len(units), why="the declared table to receive units") assert not notice.display last_good = tuple(rows.children) - names = tuple(plan.name for plan in FieldCodec.field_plans(CommanderUnit) - if plan.records and not plan.excluded_in("hud")) - assert tuple(key.value for key in table.columns) == ( - *names, *FieldCodec.projections(CommanderUnit, "hud"), - ) - assert [column.label.plain for column in table.ordered_columns] == [ - "Unit", "State", "Work", "Change", "Next", - ] + columns = FieldCodec.view_columns(CommanderUnit, "hud") + assert table.column_plans is columns + assert tuple(key.value for key in table.columns) == tuple(column.name for column in columns) + assert tuple(column.label.plain for column in table.ordered_columns) == tuple( + column.label for column in columns) + assert str(view.group_headings.render()) == "\n".join( + f"{section.group.heading()} · {len(section.units)}" for section in hud.groups()) assert UnitTone.of(Blocked) is ErrorColour assert UnitTone.of(Unreadable) is ErrorColour assert UnitTone.of(Detached) is WarningColour @@ -97,7 +104,8 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace) assert table.selected_key == moving retained_row = table.rows[moving] assert str(view.heading.render()) == "Commander claude-code" - assert str(view.status.render()).startswith(str(hud.load)) + assert str(view.status.render()).startswith( + f"LOAD {hud.load.total:.0f} · REVIEW UP {hud.load.timers_up} · UNJUDGED {hud.load.unjudged}") assert hud.usage.line() in str(view.footer.render()) assert "forks: fork-hud" in str(view.footer.render()) await pilot.resize_terminal(80, 40) @@ -123,7 +131,7 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace) assert table.rows[moving] is retained_row assert table.selected.strikes == 2 assert tuple(card.unit for card in table.records.values()) == tuple( - card.unit for card in repaired.units + card.unit for section in repaired.groups() for card in section.units ) view.filter.value = "hud-worker-moving" await ui.until(lambda: table.row_count == 1, why="the cell-text filter") From 942367d78e24fa0f511b6f75cdb96ffd9bb39c39 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 06:09:26 -0400 Subject: [PATCH 06/15] Render native commander groups from shared Core tables --- src/toad/screens/commander.py | 383 ++++++++++++++---------- src/toad/widgets/declared_disclosure.py | 42 +-- src/toad/widgets/declared_table.py | 164 +++++----- 3 files changed, 310 insertions(+), 279 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 085b31834..4f8092d2e 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -1,31 +1,28 @@ -"""A declaration-driven table over 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 ViewTable 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 ( - Blocked, HudGroup, IdleGroup, InProgressGroup, ReadyToTestGroup, - UnitState, Unreadable, UrgentGroup, -) +from agent_comms.ui_model.unit_state import HudGroup, InProgressGroup, ReadyToTestGroup, UrgentGroup from agent_comms.work_branches import WorkBranchError from metaclass_registry.mro_dispatch import MroDispatch, handles from textual import on, work from textual.app import ComposeResult from textual.containers import HorizontalGroup, Vertical, VerticalGroup, VerticalScroll from textual.content import Content +from textual.message import Message from textual.style import Style -from textual.widgets import Button, Checkbox, Collapsible, DataTable, Input, Static -from ui_theme.scheme import ( - AccentColour, ErrorColour, ForegroundMutedColour, SuccessColour, ThemeColour, WarningColour, -) +from textual.widgets import Button, Checkbox, Collapsible, Input, Static +from ui_theme.scheme import AccentColour, ForegroundMutedColour, SuccessColour, ThemeColour, WarningColour from toad.app_node import ToadNode from toad.core.events import CommanderHudChanged, CommanderHudFailed @@ -33,49 +30,29 @@ from toad.screens.modal import DismissibleScreen from toad.widget_actions import DismissAction from toad.widgets.declared_disclosure import ( - DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, + DeclaredDisclosure, DisclosureEntry, DisclosureKey, DisclosedValue, RootKey, ) from toad.widgets.declared_table import DeclaredTable from toad.widgets.sidebar_tree import KeyedRows -class HudRowText(MroDispatch): - """Core's ordinary rows; units and board entries have their own views.""" - - def lines(self, row: HudRow) -> tuple[str, ...]: - return type(self).handler_for(type(row))(self, row) - - @handles(HudRow) - def row(self, row: HudRow) -> tuple[str, ...]: - return (row.line(),) - - @handles(CommanderUnit, QuietUnits, BoardRow) - def disclosed(self, row: HudRow) -> tuple[str, ...]: - return () - - class UnitTone(MroDispatch): - """Core's nominal state family supplies the row's tone through its MRO.""" + """Adapt Core's group declaration to the active native theme, never its state names.""" @classmethod - def of(cls, state: type[UnitState | HudGroup]) -> type[ThemeColour]: - return cls.handler_for(state)(state) + def of(cls, group: type[HudGroup]) -> type[ThemeColour]: + return cls.handler_for(group)(group) - @handles(UnitState) + @handles(HudGroup) @staticmethod - def state(state: type[UnitState]) -> type[ThemeColour]: - return UnitTone.of(state.hud_group) + def ordinary(group: type[HudGroup]) -> type[ThemeColour]: + return ForegroundMutedColour @handles(UrgentGroup) @staticmethod def attention(group: type[UrgentGroup]) -> type[ThemeColour]: return WarningColour - @handles(Blocked, Unreadable) - @staticmethod - def blocked(state: type[UnitState]) -> type[ThemeColour]: - return ErrorColour - @handles(ReadyToTestGroup) @staticmethod def application(group: type[ReadyToTestGroup]) -> type[ThemeColour]: @@ -86,19 +63,158 @@ def application(group: type[ReadyToTestGroup]) -> type[ThemeColour]: def moving(group: type[InProgressGroup]) -> type[ThemeColour]: return SuccessColour - @handles(IdleGroup) - @staticmethod - def idle(group: type[IdleGroup]) -> type[ThemeColour]: - return ForegroundMutedColour +class CommanderTable(DeclaredTable[HudRecord]): + class Selected(Message): + def __init__(self, record: HudRecord | None) -> None: + self.record = record + super().__init__() + + def row_tone(self, record: HudRecord) -> type[ThemeColour]: + return UnitTone.of(record.hud_group) + + def preferred_entity(self) -> DisclosureKey: + return self.query_ancestor(CommanderFrameView).selection_key + + def selection_changed(self) -> None: + frame = self.query_ancestor(CommanderFrameView) + record = self.selected + key = DisclosedValue.of(record).sequence_key() + if self.has_focus or key == frame.selection_key: + self.post_message(self.Selected(record)) + + +class CommanderGroup(KeyedRows[type[HudRecord]], VerticalGroup): + """One native band really contains its rows, including ready unheld plan phases.""" + + 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: + row.present(self.sources[key]) + + 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)) + 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(Collapsible): + """The alarm family owns the title/count; this view retains expansion across beats.""" + + 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: + self.title = Content(section.heading()) + self.original.update("\n".join(CommanderHud.alarm_detail((section,), now))) + self.display = True + + +class CommanderDetails(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: HudRecord | None = None + self.status = Static("Highlight a record to read its original details.", markup=False) + self.original = VerticalGroup() + super().__init__(classes="hud-detail") + + def compose(self) -> ComposeResult: + yield self.status + yield Button("Refresh original details", classes="refresh-unit", compact=True) + yield self.original + + def present(self, card: HudRecord | None) -> None: + if self.card == card: + return + self.card = card + self.read_detail() + + @on(Button.Pressed, ".refresh-unit") + def refresh_detail(self, event: Button.Pressed) -> None: + event.stop() + self.read_detail() + + @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, + tuple(dict.fromkeys(order.order for order in card.orders)), + )) + + @work(exclusive=True, group="commander-detail") + async def read_detail(self) -> None: + card = self.card + self.original.display = False + if card is None: + self.status.update("No records match the filter.") + return + label = DisclosedValue.of(card).summary() + self.status.update(f"Reading original records for {label}…") + try: + 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.card == card: + self.status.update(f"Original details unavailable: {error}") + return + if not self.is_attached or self.card != card: + return + entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) + for view in self.original.query_children(DeclaredDisclosure): + view.present(entry) + break + else: + self.original.mount(DeclaredDisclosure(entry)) + self.original.display = True + self.status.update("Original records; unit activity is bounded by Core.") -class CommanderTable(DeclaredTable[CommanderUnit], record=CommanderUnit, view="hud"): - def row_tone(self, record: CommanderUnit) -> type[ThemeColour]: - return UnitTone.of(record.state) + def on_unmount(self) -> None: + self.workers.cancel_group(self, "commander-detail") -class CommanderFrameView(ToadNode, VerticalGroup): - """One current frame, one native table selection, one lazy original-detail pane.""" +class CommanderFrameView(KeyedRows[type[HudGroup]], ToadNode, VerticalGroup): + """Keep native groups, filters, selection and original details across frame replacement.""" DEFAULT_CSS = """ CommanderFrameView .hud-filter { height: auto; } @@ -106,133 +222,95 @@ class CommanderFrameView(ToadNode, VerticalGroup): CommanderFrameView .hud-filter Checkbox { width: auto; } CommanderTable { height: auto; max-height: 50vh; } CommanderFrameView .hud-detail { height: auto; } - CommanderFrameView .detail-status { color: $text-muted; } CommanderFrameView .refresh-unit { width: auto; margin: 0; } + CommanderGroup { height: auto; } + CommanderGroup .hud-group-heading { text-style: bold; } """ - row_text = HudRowText() - def __init__(self, hud: CommanderHud) -> None: self.hud = hud + self.selected = next((record for section in hud.groups() for record in section.records), None) self.heading = Static("", markup=False, classes="hud-commander") self.status = Static("", markup=False, classes="hud-load") - self.group_headings = Static("", markup=False, classes="hud-groups") - self.table = CommanderTable() - self.filter = Input(placeholder="Filter units, state, work or changes") + self.groups = VerticalGroup() + self.filter = Input(placeholder="Filter commander records") self.needs_hand = Checkbox("Needs hand") - self.detail_status = Static("Highlight a unit to read its original records.", - markup=False, classes="detail-status") - self.original_details = VerticalGroup() - self.footer = Static("", markup=False) - self.board_text = Static("", markup=False) - self.board = Collapsible(self.board_text, title="", collapsed=True) + 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 - yield self.group_headings - yield self.board + yield from self.alarms.values() with HorizontalGroup(classes="hud-filter"): yield self.filter yield self.needs_hand - yield self.table - with VerticalGroup(classes="hud-detail"): - yield self.detail_status - yield Button("Refresh original details", classes="refresh-unit", compact=True) - yield self.original_details - yield self.footer + yield self.groups + yield self.details - def on_mount(self) -> None: - self.present(self.hud) - self.filter_units() + @property + def selection_key(self) -> DisclosureKey: + return DisclosedValue.of(self.selected).sequence_key() + + @property + def sections(self) -> dict[type[HudGroup], HudSection]: + text = self.filter.value.casefold() + return {section.group: HudSection(section.group, tuple( + replace(table, 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())) + for table in section.tables + )) for section in self.hud.groups()} + + @property + def member_container(self) -> VerticalGroup: + return self.groups + + @property + def rows(self) -> dict[type[HudGroup], CommanderGroup]: + return {view.section.group: view for view in self.groups.query_children(CommanderGroup)} + + def create_row(self, key: type[HudGroup]) -> CommanderGroup: + return CommanderGroup(self.sections[key]) + + def update_row(self, key: type[HudGroup], row: CommanderGroup) -> None: + row.present(self.sections[key]) @on(Input.Changed) @on(Checkbox.Changed) def filter_changed(self) -> None: - self.filter_units() + self.call_after_refresh(self.render_frame) - def filter_units(self) -> None: - text = self.filter.value.casefold() - selected = self.table.selected - self.table.present( - card for section in self.hud.groups() for card in section.units - if (not self.needs_hand.value or card.needs_hand()) - and text in "\n".join(cell.plain for cell in self.table.cells(card)).casefold() - ) - if self.table.selected != selected: - self.read_detail() - - @on(DataTable.RowHighlighted) - @on(DataTable.RowSelected) - def highlight_unit(self) -> None: - self.read_detail() - - @on(Button.Pressed, ".refresh-unit") - def refresh_detail(self, event: Button.Pressed) -> None: + @on(CommanderTable.Selected) + def highlight_record(self, event: CommanderTable.Selected) -> None: event.stop() - self.read_detail() - - @work(exclusive=True, group="commander-detail") - async def read_detail(self) -> None: - card = self.table.selected - self.original_details.display = False - if card is None: - self.detail_status.update("No units match the filter.") - return - self.detail_status.update(f"Reading original orders and activity for {card.unit.name}…") - access = self.app.coordination_access - try: - observed = await access.preparation.run_thread(access.observation.observed) - request = ReadCommanderUnit( - str(observed.service.root), self.hud.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.table.selected == card: - self.detail_status.update(f"Original details unavailable: {error}") - return - if not self.is_attached or self.table.selected != card: - return - entry = DisclosureEntry( - RootKey(type(detail)), "Original details", DisclosedValue.of(detail), - ) - for view in self.original_details.query_children(DeclaredDisclosure): - view.present(entry) - break - else: - self.original_details.mount(DeclaredDisclosure(entry)) - self.original_details.display = True - self.detail_status.update(f"Original records for {card.unit.name}; activity is bounded by Core.") + self.selected = event.record + self.details.present(event.record) def present(self, hud: CommanderHud) -> None: self.hud = hud - self.heading.update(f"Commander {hud.commander.name}") + self.call_after_refresh(self.render_frame) + + def render_frame(self) -> None: + self.heading.update(f"Commander {self.hud.commander.name}") now = time.time() - self.status.update(hud.status_strip(now)) - self.group_headings.update(Content("\n").join( - Content(f"{section.group.heading()} · {len(section.units)}").stylize( - Style(foreground=self.app.colours.colour(UnitTone.of(section.group)))) - for section in hud.groups() - )) - self.footer.update("\n".join( - line for row in hud.rows() for line in self.row_text.lines(row) - )) - count = sum(len(packet.handled) for packet in hud.packets) - self.board.title = Content(f"{count} original alarms") - self.board_text.update("\n".join(hud.alarm_detail(hud.packets, now))) - self.board.display = bool(count) - if self.is_mounted: - self.filter_units() + 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) + sections = self.sections + records = {DisclosedValue.of(record).sequence_key(): record + for section in sections.values() for record in section.records} + self.selected = records.get(self.selection_key, next(iter(records.values()), None)) + self.details.present(self.selected) + keys = tuple(sections) + self.reconcile_keys(keys, changed=frozenset(keys)) -class CommanderPage( - KeyedRows[ThreadIncarnation], CoreEventReceiver, DismissibleScreen[None], -): +class CommanderPage(KeyedRows[ThreadIncarnation], CoreEventReceiver, DismissibleScreen[None]): """Current Core frames preserve table selection, focus and the conversation below.""" DEFAULT_CSS = """ @@ -246,7 +324,7 @@ class CommanderPage( CommanderPage CommanderFrameView { height: auto; } CommanderPage Static { height: auto; } CommanderPage .hud-commander { text-style: bold reverse; } - CommanderPage .hud-load, CommanderPage .hud-groups { text-style: bold; } + CommanderPage .hud-load { text-style: bold; } CommanderPage #commander-close { dock: bottom; margin: 0; } """ @@ -261,10 +339,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]: @@ -298,10 +373,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 af0951b50..a1a220c5a 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -2,16 +2,16 @@ from __future__ import annotations +from abc import ABC, abstractmethod from dataclasses import dataclass from functools import singledispatchmethod from typing import Hashable from agent_comms.disclosure_text import ( - ClassText, DisclosureText, FamilyText, IdentifiedRecordText, + ClassText, DisclosureText, IdentifiedRecordText, MappingText, RecordText, SequenceText, ) from agent_comms.field_codec import FieldCodec -from python_introspect import declared_field_label from textual import events from textual.containers import VerticalGroup from textual.content import Content @@ -58,10 +58,6 @@ class DisclosedValue: source: DisclosureText - @property - def value(self) -> object: - return self.source.value - @classmethod def of(cls, value: object) -> DisclosedValue: return cls.from_text(DisclosureText.of(value)) @@ -77,11 +73,6 @@ def from_text(cls, source: DisclosureText) -> DisclosedValue: def _class(cls, source: ClassText) -> DisclosedValue: return ClassValue(source) - @from_text.register(FamilyText) - @classmethod - def _family_class(cls, source: FamilyText) -> DisclosedValue: - return FamilyClassValue(source) - @from_text.register(RecordText) @classmethod def _record(cls, source: RecordText) -> DisclosedValue: @@ -106,18 +97,18 @@ def sequence_key(self) -> DisclosureKey: return AnonymousKey() def identity_part(self) -> object: - return self.value + return self.source.value def summary(self, width: int | None = None) -> str: return self.source.summary(width) def cell(self) -> Content: - return Content(str(self.value)) + return Content(str(self.source.value)) def show(self, view: DeclaredDisclosure) -> None: view.body.display = True view.members.display = False - view.body.update(str(self.value)) + view.body.update(str(self.source.value)) @dataclass(frozen=True) @@ -133,17 +124,12 @@ def show(self, view: DeclaredDisclosure) -> None: view.body.update(self.summary()) -@dataclass(frozen=True) -class FamilyClassValue(ClassValue): - source: FamilyText - - -class StructuredValue(DisclosedValue): +class StructuredValue(DisclosedValue, ABC): def cell(self) -> Content: return Content(self.summary()) - def children(self) -> tuple[DisclosureEntry, ...]: - raise NotImplementedError + @abstractmethod + def children(self) -> tuple[DisclosureEntry, ...]: ... def show(self, view: DeclaredDisclosure) -> None: view.body.display = False @@ -156,11 +142,11 @@ class RecordValue(StructuredValue): 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)), + FieldKey(type(self.source.value), plan.name), + plan.label, + DisclosedValue.of(plan.current(self.source.value)), ) - for plan in FieldCodec.field_plans(type(self.value)) if plan.records + for plan in FieldCodec.field_plans(type(self.source.value)) if plan.records ) @@ -169,8 +155,8 @@ class IdentifiedRecordValue(RecordValue): source: IdentifiedRecordText def sequence_key(self) -> EntityKey: - return EntityKey(type(self.value), tuple( - DisclosedValue.of(plan.current(self.value)).identity_part() + return EntityKey(type(self.source.value), tuple( + DisclosedValue.of(plan.current(self.source.value)).identity_part() for plan in self.source.identity )) diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index d66456169..a1a4a6f5f 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -1,49 +1,43 @@ -"""A keyed table whose columns and cells come from record declarations.""" +"""Native keyed rows over Core's declaration-owned table presentation.""" from __future__ import annotations -from collections.abc import Iterable - -from agent_comms.field_codec import FieldCodec, ViewColumn -from rich.cells import cell_len -from textual.style import Style -from textual._two_way_dict import TwoWayDict +from agent_comms.disclosure_text import 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 -from toad.widgets.declared_disclosure import DisclosureKey, DisclosedValue +from toad.widgets.declared_disclosure import AnonymousKey, DisclosureKey, DisclosedValue class DeclaredTable[R](MovesCursor, OpensSelected, DataTable[Content]): - """Keep native rows and cursor identity while the source replaces its records.""" - - record: type[R] - view: str + """Core owns columns and measurement; DataTable owns rows, geometry and repaint.""" - def __init_subclass__(cls, *, record: type[R], view: str, **kwargs) -> None: - cls.record = record - cls.view = view - super().__init_subclass__(**kwargs) - - def __init__(self, **kwargs) -> None: + def __init__(self, source: ViewTable[R], **kwargs) -> None: super().__init__(cursor_type="row", show_row_labels=False, **kwargs) - self.column_plans = FieldCodec.view_columns(self.record, self.view) + self.source = source self.records: dict[RowKey, R] = {} self.entity_keys: dict[DisclosureKey, RowKey] = {} + self.call_after_refresh(self.reconcile_source) - def on_mount(self) -> None: - for column in self.column_plans: - self.add_column(column.label, key=column.name) - self.present(tuple(self.records.values())) + def present(self, source: ViewTable[R]) -> None: + self.source = source + # The native message pump admits 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 cells(self, record: R) -> tuple[Content, ...]: - return tuple(DisclosedValue.of(column.current(record)).cell() for column in self.column_plans) + def selection_changed(self) -> None: + """A table with a detail pane owns what highlighting a native row does.""" + + def preferred_entity(self) -> DisclosureKey: + """A shared reader may retain its selection when a record moves between tables.""" + return AnonymousKey() @property def selected_key(self) -> RowKey | None: @@ -53,84 +47,62 @@ def selected_key(self) -> RowKey | None: @property def selected(self) -> R | None: - return self.records.get(self.selected_key) - - def present(self, records: Iterable[R]) -> None: - selected = self.selected_key - entities = {DisclosedValue.of(record).sequence_key(): record for record in records} + key = self.selected_key + return None if key is None else self.records.get(key) + + def reconcile_source(self) -> None: + selected, previous = self.selected_key, self.selected + offset = self.scroll_offset + entities = {DisclosedValue.of(record).sequence_key(): record + for record in self.source.records} + if len(entities) != len(self.source.records): + raise ValueError("A declared table requires unique record identities") keys = {entity: self.entity_keys.get(entity, RowKey()) for entity in entities} + selected = keys.get(self.preferred_entity(), selected) incoming = {keys[entity]: record for entity, record in entities.items()} - # A parent's Mount can supply the frame before this table's Mount creates columns. - # Keep the supplied records; the table's own column owner admits their native rows. - if not self.columns: - self.records, self.entity_keys = incoming, keys - return - for key in self.records.keys() - incoming.keys(): - self.remove_row(key) - changed: set[RowKey] = set() - for key, record in incoming.items(): - if key in self.rows and self.records[key] == record: - continue + rows = [] + for record, cells in zip(incoming.values(), self.source.cells): colour = self.app.colours.colour(self.row_tone(record)) style = Style(background=colour, foreground=colour.get_contrast_text()) - cells = tuple(cell.stylize(style) for cell in self.cells(record)) - if key in self.rows: - for column, cell in zip(self.column_plans, cells): - self.update_cell(key, column.name, cell) - else: - self.add_row(*cells, key=key, height=None) - changed.add(key) - self.records = incoming - self.entity_keys = keys - # DataTable has no public keyed reorder. Its own sort() uses this same index; - # retain Core's supplied order without inventing another state sort in the UI. - self._row_locations = TwoWayDict({key: index for index, key in enumerate(incoming)}) - self._update_count += 1 - self.reflow_rows(changed) - self.fit_columns() - if selected in incoming: - self.move_cursor(row=self.get_row_index(selected), animate=False) - elif incoming: - self.move_cursor(row=min(self.cursor_row, len(incoming) - 1), animate=False) - - def fit_columns(self) -> None: - columns = self.ordered_columns - if not columns: - return - weights = [] - for column in columns: - weight = max((cell_len(line) - for cell in self.get_column(column.key) - for line in cell.plain.splitlines()), default=0) - column.content_width = max(cell_len(column.label.plain), weight) - weights.append(max(1, column.content_width)) - available = max(len(columns), self.size.width - self.scrollbar_size_vertical - - self.cell_padding * 2 * len(columns)) - minimums = tuple(plan.minimum_width(cell.plain for cell in self.get_column(column.key)) - for plan, column in zip(self.column_plans, columns)) - widths = ViewColumn.fit_widths(weights, available, minimums) - if widths == tuple(column.width for column in columns) and not any( - column.auto_width for column in columns - ): + rows.append(tuple(DisclosedValue.from_text(cell).cell().stylize(style) for cell in cells)) + available = max(len(self.source.columns), self.size.width - self.scrollbar_size_vertical + - self.cell_padding * 2 * len(self.source.columns)) + widths = self.source.widths(available) + columns_changed = tuple((column.key.value, column.width) for column in self.ordered_columns) != tuple( + (column.name, width) for column, width in zip(self.source.columns, widths) + ) + same_rows = (tuple(row.key for row in self.ordered_rows) == tuple(incoming) + and tuple(tuple(self.get_row(key)) for key in incoming) == tuple(rows)) + self.records, self.entity_keys = incoming, keys + if same_rows and not columns_changed: return - for column, width in zip(columns, widths): - column.auto_width = False - column.width = width - self.reflow_rows(self.rows) - - def reflow_rows(self, keys: Iterable[RowKey]) -> None: - # Re-measure native auto-height only after cell text or column widths change. - # A height-only Resize must not zero the rows again: that feeds its own layout. - for key in keys: - self.rows[key].height = 0 - self._new_rows.add(key) - self._require_update_dimensions = True - self._update_count += 1 - self._clear_caches() - self.refresh(layout=True) + # Public clear/add owns auto-height remeasurement and native row ordering. + # Retain row keys, cursor and scroll rather than mutating Textual's caches. + with self.prevent(DataTable.RowHighlighted): + self.clear(columns=columns_changed) + if columns_changed: + for column, width in zip(self.source.columns, widths): + self.add_column(column.label, key=column.name, width=width) + for key, cells in zip(incoming, rows): + self.add_row(*cells, key=key, height=None) + if selected in incoming: + self.move_cursor(row=self.get_row_index(selected), animate=False, scroll=False) + elif incoming: + self.move_cursor(row=min(self.cursor_row, len(incoming) - 1), animate=False, scroll=False) + self.scroll_to(x=offset.x, y=offset.y, animate=False, force=True) + if not same_rows and self.selected != previous: + self.selection_changed() def on_resize(self) -> None: - self.fit_columns() + self.call_after_refresh(self.reconcile_source) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + event.stop() + self.selection_changed() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + event.stop() + self.selection_changed() def cursor_up(self) -> None: DataTable.action_cursor_up(self) From d4d04a4439936702d056421d301ee2fac20273fb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 06:33:36 -0400 Subject: [PATCH 07/15] Use shared cell text and delete superseded commander fixture --- src/toad/widgets/declared_disclosure.py | 9 -- src/toad/widgets/declared_table.py | 4 +- tests/textual/test_commander_page.py | 167 ------------------------ 3 files changed, 2 insertions(+), 178 deletions(-) delete mode 100644 tests/textual/test_commander_page.py diff --git a/src/toad/widgets/declared_disclosure.py b/src/toad/widgets/declared_disclosure.py index a1a220c5a..71a4694ca 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -102,9 +102,6 @@ def identity_part(self) -> object: def summary(self, width: int | None = None) -> str: return self.source.summary(width) - def cell(self) -> Content: - return Content(str(self.source.value)) - def show(self, view: DeclaredDisclosure) -> None: view.body.display = True view.members.display = False @@ -115,9 +112,6 @@ def show(self, view: DeclaredDisclosure) -> None: class ClassValue(DisclosedValue): source: ClassText - def cell(self) -> Content: - return Content(self.summary()) - def show(self, view: DeclaredDisclosure) -> None: view.body.display = True view.members.display = False @@ -125,9 +119,6 @@ def show(self, view: DeclaredDisclosure) -> None: class StructuredValue(DisclosedValue, ABC): - def cell(self) -> Content: - return Content(self.summary()) - @abstractmethod def children(self) -> tuple[DisclosureEntry, ...]: ... diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index a1a4a6f5f..41d47d98d 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -61,10 +61,10 @@ def reconcile_source(self) -> None: selected = keys.get(self.preferred_entity(), selected) incoming = {keys[entity]: record for entity, record in entities.items()} rows = [] - for record, cells in zip(incoming.values(), self.source.cells): + for record, texts in zip(incoming.values(), self.source.texts): colour = self.app.colours.colour(self.row_tone(record)) style = Style(background=colour, foreground=colour.get_contrast_text()) - rows.append(tuple(DisclosedValue.from_text(cell).cell().stylize(style) for cell in cells)) + rows.append(tuple(Content(text).stylize(style) for text in texts)) available = max(len(self.source.columns), self.size.width - self.scrollbar_size_vertical - self.cell_padding * 2 * len(self.source.columns)) widths = self.source.widths(available) diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py deleted file mode 100644 index 1ed2b1afa..000000000 --- a/tests/textual/test_commander_page.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Textual-facing: declared commander rows survive refresh and source failures.""" - -import json -from dataclasses import replace - -from agent_comms.commander.vibe_check import OverloadDegradation -from agent_comms.commander_hud import ( - CommanderFrame, CommanderHud, CommanderOrder, CommanderUnit, CodexUsage, UnreadableUsage, -) -from agent_comms.field_codec import FieldCodec, OrderId -from agent_comms.thread_identity import RecordedThreadIncarnation -from agent_comms.todos import NoCommitProgress -from agent_comms.ui_model.observation import ObserveCommanderHud -from agent_comms.ui_model.unit_state import ( - AwaitingApplicationRun, Blocked, Detached, Idle, Moving, PendingClock, UnitState, Unreadable, -) -from textual.widgets import Static -from ui_theme.scheme import ( - AccentColour, ErrorColour, ForegroundMutedColour, SuccessColour, WarningColour, -) - -from toad.comms_root import FailedObservation, UnselectedRoute -from toad.core.events import CommanderHudChanged -from toad.screens.commander import CommanderFrameView, CommanderPage, CommanderTable, UnitTone - - -async def test_commander_page_survives_unreadable_frames_and_recovers(workspace): - order = CommanderOrder( - "P927", "Make the commander readable", OrderId("123456789abc"), - "pending", NoCommitProgress(), (), 0, 0, - ) - units = tuple( - CommanderUnit( - RecordedThreadIncarnation(f"hud-worker-{state.declared_name}", float(index + 2)), - state, PendingClock(1.0, 1.0), - (order, replace(order, problem="P928", order=OrderId("23456789abcd"))), - 1, (), (), - ) - for index, state in enumerate(UnitState.members_with(UnitState)) - ) - hud = CommanderHud( - RecordedThreadIncarnation("claude-code", 1.0), units, (), - OverloadDegradation.from_orders((), 0, 0), - (RecordedThreadIncarnation("fork-hud", 200.0),), - CodexUsage(UnreadableUsage(reason="not requested")), - ) - CommanderFrame.replace_all(workspace.comms.root, (hud,)) - frame, = CommanderFrame.matching_in(workspace.comms.root) - written = json.loads(frame.read_text()) - unreadable = json.dumps({**written, "field_from_a_newer_build": []}) - frame.write_text(unreadable) - async with workspace.app() as ui: - app, pilot = ui.textual - page = CommanderPage() - await app.push_screen(page) - notice = page.query_one("#hud-error", Static) - await ui.until(lambda: notice.display, why="the unreadable frame to reach the page") - assert "field_from_a_newer_build" in str(notice.render()) - rows = page.query_one("#hud-rows") - assert [str(row.render()) for row in rows.query(Static)] == [ - "Waiting for the commander heartbeat.", - ] - - CommanderFrame.replace_all(workspace.comms.root, (hud,)) - await ui.until( - lambda: bool(rows.query_children(CommanderFrameView)) - or any(notification.severity == "error" for notification in app._notifications), - why="the actual frame mount, or its original application error", - ) - errors = tuple(notification.message for notification in app._notifications - if notification.severity == "error") - assert not errors, "\n".join(errors) - await ui.until_shows("Commander claude-code") - view = rows.query_one(CommanderFrameView) - table = view.query_one(CommanderTable) - await ui.until(lambda: table.row_count == len(units), why="the declared table to receive units") - assert not notice.display - last_good = tuple(rows.children) - columns = FieldCodec.view_columns(CommanderUnit, "hud") - assert table.column_plans is columns - assert tuple(key.value for key in table.columns) == tuple(column.name for column in columns) - assert tuple(column.label.plain for column in table.ordered_columns) == tuple( - column.label for column in columns) - assert str(view.group_headings.render()) == "\n".join( - f"{section.group.heading()} · {len(section.units)}" for section in hud.groups()) - assert UnitTone.of(Blocked) is ErrorColour - assert UnitTone.of(Unreadable) is ErrorColour - assert UnitTone.of(Detached) is WarningColour - assert UnitTone.of(AwaitingApplicationRun) is AccentColour - assert UnitTone.of(Moving) is SuccessColour - assert UnitTone.of(Idle) is ForegroundMutedColour - for key, card in table.records.items(): - assert table.get_cell(key, "unit").plain == card.unit.name - state = table.get_cell(key, "state") - assert state.plain == card.state.declared_name - assert state.spans[-1].style.background == app.colours.colour(UnitTone.of(card.state)) - assert table.get_cell(key, "work").plain == "P927 pending\nP928 pending" - assert table.rows[key].height > 1 - moving = next(key for key, card in table.records.items() if card.state is Moving) - table.move_cursor(row=table.get_row_index(moving)) - table.focus() - await ui.step() - await ui.until_shows("Original details unavailable:") - assert table.selected_key == moving - retained_row = table.rows[moving] - assert str(view.heading.render()) == "Commander claude-code" - assert str(view.status.render()).startswith( - f"LOAD {hud.load.total:.0f} · REVIEW UP {hud.load.timers_up} · UNJUDGED {hud.load.unjudged}") - assert hud.usage.line() in str(view.footer.render()) - assert "forks: fork-hud" in str(view.footer.render()) - await pilot.resize_terminal(80, 40) - await ui.step() - assert sum(column.get_render_width(table) for column in table.ordered_columns) <= table.size.width - assert table.selected_key == moving - - 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 - - repaired = replace( - hud, units=tuple(replace(unit, strikes=2) for unit in reversed(units)), - forks=(RecordedThreadIncarnation("recovered", 201.0),), - ) - CommanderFrame.replace_all(workspace.comms.root, (repaired,)) - await ui.until_shows("forks: recovered") - assert not notice.display - assert rows.query_one(CommanderFrameView) is view - assert view.query_one(CommanderTable) is table - assert table.selected_key == moving and table.has_focus - assert table.rows[moving] is retained_row - assert table.selected.strikes == 2 - assert tuple(card.unit for card in table.records.values()) == tuple( - card.unit for section in repaired.groups() for card in section.units - ) - view.filter.value = "hud-worker-moving" - await ui.until(lambda: table.row_count == 1, why="the cell-text filter") - assert table.selected_key == moving - view.needs_hand.value = True - await ui.until(lambda: table.row_count == 0, why="the needs-hand filter to exclude moving") - view.filter.value = "" - await ui.until(lambda: table.row_count == sum(card.needs_hand() for card in repaired.units), - why="the needs-hand filter to use Core's answer") - recovered_rows = tuple(rows.children) - failed = FailedObservation( - OSError("observer process exited"), UnselectedRoute(), - access=app.coordination_access, - ) - failed.observe_commander(ObserveCommanderHud()) - await ui.until(lambda: notice.display, why="the observer failure notice") - assert str(notice.render()) == "Commander rows unavailable: observer process exited" - assert tuple(rows.children) == recovered_rows - await pilot.press("escape") - await ui.step() - assert app.screen is not page - - -async def test_a_heartbeat_with_no_frames_still_says_it_is_waiting(workspace): - async with workspace.app() as ui: - app, _pilot = ui.textual - page = CommanderPage() - await app.push_screen(page) - await ui.step() - app.coordination_access.events.publish(CommanderHudChanged(())) - await ui.step() - rows = page.query_one("#hud-rows").query(Static) - assert [str(row.render()) for row in rows] == ["Waiting for the commander heartbeat."] From 0794947676165e9f780ed0a8eeee54c26674e51a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 08:14:15 -0400 Subject: [PATCH 08/15] Consume HUD request keys, tone tiers and stacked layouts --- src/toad/screens/commander.py | 69 ++++++++++++++++++------------ src/toad/widgets/declared_table.py | 13 +++--- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 4f8092d2e..2fc7158c8 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -7,12 +7,14 @@ from agent_comms.commander_hud import CommanderHud, CommanderUnit, HudRecord, HudSection from agent_comms.coordination_errors import DeferredRead -from agent_comms.disclosure_text import ViewTable +from agent_comms.disclosure_text import ( + AccentTone, ErrorTone, HudTone, 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, InProgressGroup, ReadyToTestGroup, UrgentGroup +from agent_comms.ui_model.observation import 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 textual import on, work @@ -22,7 +24,9 @@ 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, ForegroundMutedColour, SuccessColour, ThemeColour, WarningColour +from ui_theme.scheme import ( + AccentColour, ErrorColour, ForegroundMutedColour, SuccessColour, ThemeColour, WarningColour, +) from toad.app_node import ToadNode from toad.core.events import CommanderHudChanged, CommanderHudFailed @@ -37,32 +41,37 @@ class UnitTone(MroDispatch): - """Adapt Core's group declaration to the active native theme, never its state names.""" + """Bind Core's shared tone tiers to native theme colours, not group semantics.""" @classmethod - def of(cls, group: type[HudGroup]) -> type[ThemeColour]: - return cls.handler_for(group)(group) + def of(cls, tone: type[HudTone]) -> type[ThemeColour]: + return cls.handler_for(tone)(tone) - @handles(HudGroup) + @handles(HudTone) @staticmethod - def ordinary(group: type[HudGroup]) -> type[ThemeColour]: + def ordinary(tone: type[HudTone]) -> type[ThemeColour]: return ForegroundMutedColour - @handles(UrgentGroup) + @handles(WarningTone) @staticmethod - def attention(group: type[UrgentGroup]) -> type[ThemeColour]: + def attention(tone: type[WarningTone]) -> type[ThemeColour]: return WarningColour - @handles(ReadyToTestGroup) + @handles(AccentTone) @staticmethod - def application(group: type[ReadyToTestGroup]) -> type[ThemeColour]: + def accent(tone: type[AccentTone]) -> type[ThemeColour]: return AccentColour - @handles(InProgressGroup) + @handles(SuccessTone) @staticmethod - def moving(group: type[InProgressGroup]) -> type[ThemeColour]: + 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): @@ -71,7 +80,7 @@ def __init__(self, record: HudRecord | None) -> None: super().__init__() def row_tone(self, record: HudRecord) -> type[ThemeColour]: - return UnitTone.of(record.hud_group) + return UnitTone.of(record.hud_group.tone) def preferred_entity(self) -> DisclosureKey: return self.query_ancestor(CommanderFrameView).selection_key @@ -121,7 +130,7 @@ def present(self, section: HudSection) -> None: self.call_after_refresh(self.render_section) def render_section(self) -> None: - colour = self.app.colours.colour(UnitTone.of(self.section.group)) + 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) @@ -138,7 +147,8 @@ def __init__(self, group: type[AlarmGroup]) -> None: self.display = False def present(self, section: AlarmSection, now: float) -> None: - self.title = Content(section.heading()) + colour = self.app.colours.colour(UnitTone.of(section.group.tone)) + self.title = Content(section.heading()).stylize(Style(foreground=colour)) self.original.update("\n".join(CommanderHud.alarm_detail((section,), now))) self.display = True @@ -158,11 +168,17 @@ def compose(self) -> ComposeResult: yield Button("Refresh original details", classes="refresh-unit", compact=True) yield self.original + @property + def detail_key(self) -> object: + if self.card is None: + return None + return self.card.detail_key(self.app.coordination_access.launch_root.path, self.commander) + def present(self, card: HudRecord | None) -> None: - if self.card == card: - return + previous = self.detail_key self.card = card - self.read_detail() + if self.detail_key != previous: + self.read_detail() @on(Button.Pressed, ".refresh-unit") def refresh_detail(self, event: Button.Pressed) -> None: @@ -177,14 +193,11 @@ async def record_detail(self, card: HudRecord) -> object: 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, - tuple(dict.fromkeys(order.order for order in card.orders)), - )) + return await observed.process.read(card.detail_key(observed.service.root, self.commander)) @work(exclusive=True, group="commander-detail") async def read_detail(self) -> None: - card = self.card + card, key = self.card, self.detail_key self.original.display = False if card is None: self.status.update("No records match the filter.") @@ -195,10 +208,10 @@ async def read_detail(self) -> None: 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.card == card: + if self.is_attached and self.detail_key == key: self.status.update(f"Original details unavailable: {error}") return - if not self.is_attached or self.card != card: + if not self.is_attached or self.detail_key != key: return entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) for view in self.original.query_children(DeclaredDisclosure): diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 41d47d98d..53e7d01b8 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -60,16 +60,17 @@ def reconcile_source(self) -> None: keys = {entity: self.entity_keys.get(entity, RowKey()) for entity in entities} selected = keys.get(self.preferred_entity(), selected) incoming = {keys[entity]: record for entity, record in entities.items()} + 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(incoming.values(), self.source.texts): + for record, texts in zip(incoming.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)) - available = max(len(self.source.columns), self.size.width - self.scrollbar_size_vertical - - self.cell_padding * 2 * len(self.source.columns)) - widths = self.source.widths(available) columns_changed = tuple((column.key.value, column.width) for column in self.ordered_columns) != tuple( - (column.name, width) for column, width in zip(self.source.columns, widths) + (column.name, width) for column, width in zip(layout.columns, widths) ) same_rows = (tuple(row.key for row in self.ordered_rows) == tuple(incoming) and tuple(tuple(self.get_row(key)) for key in incoming) == tuple(rows)) @@ -81,7 +82,7 @@ def reconcile_source(self) -> None: with self.prevent(DataTable.RowHighlighted): self.clear(columns=columns_changed) if columns_changed: - for column, width in zip(self.source.columns, widths): + for column, width in zip(layout.columns, widths): self.add_column(column.label, key=column.name, width=width) for key, cells in zip(incoming, rows): self.add_row(*cells, key=key, height=None) From e9132eb10b5c30177e13b2cc7ee0aacc74994ef2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 08:27:08 -0400 Subject: [PATCH 09/15] Retain real-card detail invalidation and show shared compact alarms --- src/toad/screens/commander.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 2fc7158c8..b5b0df32f 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -13,7 +13,7 @@ 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 UnreadableHistory +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 @@ -138,7 +138,11 @@ def render_section(self) -> None: class CommanderAlarm(Collapsible): - """The alarm family owns the title/count; this view retains expansion across beats.""" + """Core's compact section stays visible; expansion reveals every original record.""" + + DEFAULT_CSS = """ + CommanderAlarm > CollapsibleTitle { width: 1fr; } + """ def __init__(self, group: type[AlarmGroup]) -> None: self.group = group @@ -148,7 +152,8 @@ def __init__(self, group: type[AlarmGroup]) -> None: def present(self, section: AlarmSection, now: float) -> None: colour = self.app.colours.colour(UnitTone.of(section.group.tone)) - self.title = Content(section.heading()).stylize(Style(foreground=colour)) + 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 @@ -168,17 +173,11 @@ def compose(self) -> ComposeResult: yield Button("Refresh original details", classes="refresh-unit", compact=True) yield self.original - @property - def detail_key(self) -> object: - if self.card is None: - return None - return self.card.detail_key(self.app.coordination_access.launch_root.path, self.commander) - def present(self, card: HudRecord | None) -> None: - previous = self.detail_key + if self.card == card: + return self.card = card - if self.detail_key != previous: - self.read_detail() + self.read_detail() @on(Button.Pressed, ".refresh-unit") def refresh_detail(self, event: Button.Pressed) -> None: @@ -193,11 +192,13 @@ async def record_detail(self, card: HudRecord) -> object: 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(card.detail_key(observed.service.root, self.commander)) + 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) -> None: - card, key = self.card, self.detail_key + card = self.card self.original.display = False if card is None: self.status.update("No records match the filter.") @@ -208,10 +209,10 @@ async def read_detail(self) -> None: 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.detail_key == key: + if self.is_attached and self.card == card: self.status.update(f"Original details unavailable: {error}") return - if not self.is_attached or self.detail_key != key: + if not self.is_attached or self.card != card: return entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) for view in self.original.query_children(DeclaredDisclosure): @@ -256,11 +257,11 @@ def __init__(self, hud: CommanderHud) -> None: def compose(self) -> ComposeResult: yield self.heading yield self.status - yield from self.alarms.values() with HorizontalGroup(classes="hud-filter"): yield self.filter yield self.needs_hand yield self.groups + yield from self.alarms.values() yield self.details @property From 81a023cb1de1c547d311a87e43ac48fab54fe1e7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:47:36 -0400 Subject: [PATCH 10/15] Own native record selection and retain public DataTable row keys --- src/toad/screens/commander.py | 119 ++++++------ src/toad/widgets/declared_disclosure.py | 24 +-- src/toad/widgets/declared_table.py | 129 +++++++++---- tests/textual/test_commander_page.py | 232 ++++++++++++++++++++++++ 4 files changed, 392 insertions(+), 112 deletions(-) create mode 100644 tests/textual/test_commander_page.py diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index b5b0df32f..b3d228ee4 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -34,9 +34,9 @@ from toad.screens.modal import DismissibleScreen from toad.widget_actions import DismissAction from toad.widgets.declared_disclosure import ( - DeclaredDisclosure, DisclosureEntry, DisclosureKey, DisclosedValue, RootKey, + DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, ) -from toad.widgets.declared_table import DeclaredTable +from toad.widgets.declared_table import DeclaredTable, EmptyTableSelection, TableSelection from toad.widgets.sidebar_tree import KeyedRows @@ -75,25 +75,27 @@ def error(tone: type[ErrorTone]) -> type[ThemeColour]: class CommanderTable(DeclaredTable[HudRecord]): class Selected(Message): - def __init__(self, record: HudRecord | None) -> None: - self.record = record + 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) - def preferred_entity(self) -> DisclosureKey: - return self.query_ancestor(CommanderFrameView).selection_key + def preferred_selection(self) -> TableSelection[HudRecord]: + return self.query_ancestor(CommanderFrameView).selected def selection_changed(self) -> None: frame = self.query_ancestor(CommanderFrameView) - record = self.selected - key = DisclosedValue.of(record).sequence_key() - if self.has_focus or key == frame.selection_key: - self.post_message(self.Selected(record)) + selection = self.selected + if selection.key == frame.selected.key: + self.post_message(self.Selected(selection)) + def selection_highlighted(self) -> None: + self.post_message(self.Selected(self.selected)) -class CommanderGroup(KeyedRows[type[HudRecord]], VerticalGroup): + +class CommanderGroup(KeyedRows[type[HudRecord]], ToadNode, VerticalGroup): """One native band really contains its rows, including ready unheld plan phases.""" def __init__(self, section: HudSection) -> None: @@ -137,7 +139,7 @@ def render_section(self) -> None: self.reconcile_keys(keys, changed=frozenset(keys)) -class CommanderAlarm(Collapsible): +class CommanderAlarm(ToadNode, Collapsible): """Core's compact section stays visible; expansion reveals every original record.""" DEFAULT_CSS = """ @@ -163,9 +165,10 @@ class CommanderDetails(MroDispatch, ToadNode, VerticalGroup): def __init__(self, commander: ThreadIncarnation) -> None: self.commander = commander - self.card: HudRecord | None = None - self.status = Static("Highlight a record to read its original details.", markup=False) + self.selection: TableSelection[HudRecord] = EmptyTableSelection() + self.status = Static("No records match the filter.", markup=False) self.original = VerticalGroup() + self.original.display = False super().__init__(classes="hud-detail") def compose(self) -> ComposeResult: @@ -173,16 +176,16 @@ def compose(self) -> ComposeResult: yield Button("Refresh original details", classes="refresh-unit", compact=True) yield self.original - def present(self, card: HudRecord | None) -> None: - if self.card == card: + def present(self, selection: TableSelection[HudRecord]) -> None: + if self.selection == selection: return - self.card = card - self.read_detail() + self.selection = selection + self.read_detail(selection) @on(Button.Pressed, ".refresh-unit") def refresh_detail(self, event: Button.Pressed) -> None: event.stop() - self.read_detail() + self.read_detail(self.selection) @handles(HudRecord) async def record_detail(self, card: HudRecord) -> object: @@ -197,31 +200,29 @@ async def unit_detail(self, card: CommanderUnit) -> object: )) @work(exclusive=True, group="commander-detail") - async def read_detail(self) -> None: - card = self.card + async def read_detail(self, selection: TableSelection[HudRecord]) -> None: self.original.display = False - if card is None: - self.status.update("No records match the filter.") - return - label = DisclosedValue.of(card).summary() - self.status.update(f"Reading original records for {label}…") - try: - 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.card == card: - self.status.update(f"Original details unavailable: {error}") - return - if not self.is_attached or self.card != card: - return - entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) - for view in self.original.query_children(DeclaredDisclosure): - view.present(entry) - break - else: - self.original.mount(DeclaredDisclosure(entry)) - self.original.display = True - self.status.update("Original records; unit activity is bounded by Core.") + self.status.update("No records match the filter.") + for card in selection.records: + label = DisclosedValue.of(card).source.summary() + self.status.update(f"Reading original records for {label}…") + try: + 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.selection != selection: + return + entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) + for view in self.original.query_children(DeclaredDisclosure): + view.present(entry) + break + else: + 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") @@ -243,7 +244,9 @@ class CommanderFrameView(KeyedRows[type[HudGroup]], ToadNode, VerticalGroup): def __init__(self, hud: CommanderHud) -> None: self.hud = hud - self.selected = next((record for section in hud.groups() for record in section.records), None) + self.selected = TableSelection[HudRecord].from_records( + record for section in hud.groups() for record in section.records + ) self.heading = Static("", markup=False, classes="hud-commander") self.status = Static("", markup=False, classes="hud-load") self.groups = VerticalGroup() @@ -264,10 +267,6 @@ def compose(self) -> ComposeResult: yield from self.alarms.values() yield self.details - @property - def selection_key(self) -> DisclosureKey: - return DisclosedValue.of(self.selected).sequence_key() - @property def sections(self) -> dict[type[HudGroup], HudSection]: text = self.filter.value.casefold() @@ -300,14 +299,25 @@ def filter_changed(self) -> None: @on(CommanderTable.Selected) def highlight_record(self, event: CommanderTable.Selected) -> None: event.stop() - self.selected = event.record - self.details.present(event.record) + self.selected = TableSelection[HudRecord].from_records( + (record for section in self.sections.values() for record in section.records), + event.selection.key, + ) + self.details.present(self.selected) def present(self, hud: CommanderHud) -> None: self.hud = hud self.call_after_refresh(self.render_frame) + def focus_selection(self) -> None: + for table in self.query(CommanderTable): + if any(DisclosedValue.of(record).sequence_key() == self.selected.key + for record in table.source.records): + 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)) @@ -316,12 +326,15 @@ def render_frame(self) -> None: for section in self.hud.alarms: self.alarms[section.group].present(section, now) sections = self.sections - records = {DisclosedValue.of(record).sequence_key(): record - for section in sections.values() for record in section.records} - self.selected = records.get(self.selection_key, next(iter(records.values()), None)) + self.selected = TableSelection[HudRecord].from_records( + (record for section in sections.values() for record in section.records), + self.selected.key, + ) self.details.present(self.selected) keys = tuple(sections) self.reconcile_keys(keys, changed=frozenset(keys)) + if focused: + self.call_after_refresh(self.focus_selection) class CommanderPage(KeyedRows[ThreadIncarnation], CoreEventReceiver, DismissibleScreen[None]): diff --git a/src/toad/widgets/declared_disclosure.py b/src/toad/widgets/declared_disclosure.py index 71a4694ca..170eafc6c 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -8,7 +8,7 @@ from typing import Hashable from agent_comms.disclosure_text import ( - ClassText, DisclosureText, IdentifiedRecordText, + DisclosureText, IdentifiedRecordText, MappingText, RecordText, SequenceText, ) from agent_comms.field_codec import FieldCodec @@ -68,11 +68,6 @@ def from_text(cls, source: DisclosureText) -> DisclosedValue: """Adapt Core's decoded shape; never rediscover the original value's shape.""" return cls(source) - @from_text.register(ClassText) - @classmethod - def _class(cls, source: ClassText) -> DisclosedValue: - return ClassValue(source) - @from_text.register(RecordText) @classmethod def _record(cls, source: RecordText) -> DisclosedValue: @@ -99,23 +94,10 @@ def sequence_key(self) -> DisclosureKey: def identity_part(self) -> object: return self.source.value - def summary(self, width: int | None = None) -> str: - return self.source.summary(width) - - def show(self, view: DeclaredDisclosure) -> None: - view.body.display = True - view.members.display = False - view.body.update(str(self.source.value)) - - -@dataclass(frozen=True) -class ClassValue(DisclosedValue): - source: ClassText - def show(self, view: DeclaredDisclosure) -> None: view.body.display = True view.members.display = False - view.body.update(self.summary()) + view.body.update(self.source.summary()) class StructuredValue(DisclosedValue, ABC): @@ -230,7 +212,7 @@ def reconcile_entries(self, entries: tuple[DisclosureEntry, ...]) -> None: 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[:width]}: {self.entry.value.source.summary(width)}") self.title = title.truncate(width, ellipsis=True) def on_resize(self, event: events.Resize) -> None: diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 53e7d01b8..2fc37b315 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -2,6 +2,10 @@ from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Iterable +from dataclasses import dataclass + from agent_comms.disclosure_text import ViewTable from textual.content import Content from textual.style import Style @@ -13,6 +17,47 @@ from toad.widgets.declared_disclosure import AnonymousKey, DisclosureKey, DisclosedValue +@dataclass(frozen=True) +class TableSelection[R](ABC): + """Absent or present original record, never a native row key or a reader request.""" + + @property + @abstractmethod + def records(self) -> tuple[R, ...]: ... + + @property + def key(self) -> DisclosureKey: + return AnonymousKey() + + @classmethod + def from_records(cls, records: Iterable[R], *preferred: DisclosureKey) -> TableSelection[R]: + """Resolve semantic preferences against actual records, otherwise take the first.""" + choices = tuple(SelectedTableRecord(record) for record in records) + empty: TableSelection[R] = EmptyTableSelection() + return next((choice for key in preferred for choice in choices if choice.key == key), + next(iter(choices), empty)) + + +@dataclass(frozen=True) +class EmptyTableSelection[R](TableSelection[R]): + @property + def records(self) -> tuple[R, ...]: + return () + + +@dataclass(frozen=True) +class SelectedTableRecord[R](TableSelection[R]): + record: R + + @property + def records(self) -> tuple[R, ...]: + return (self.record,) + + @property + def key(self) -> DisclosureKey: + return DisclosedValue.of(self.record).sequence_key() + + class DeclaredTable[R](MovesCursor, OpensSelected, DataTable[Content]): """Core owns columns and measurement; DataTable owns rows, geometry and repaint.""" @@ -33,65 +78,73 @@ def row_tone(self, record: R) -> type[ThemeColour]: return ForegroundColour def selection_changed(self) -> None: - """A table with a detail pane owns what highlighting a native row does.""" + """The actual selected source record changed during reconciliation.""" - def preferred_entity(self) -> DisclosureKey: - """A shared reader may retain its selection when a record moves between tables.""" - return AnonymousKey() + def selection_highlighted(self) -> None: + """Native navigation may intentionally choose a different semantic record.""" + self.selection_changed() - @property - def selected_key(self) -> RowKey | None: - if not self.row_count: - return None - return self.coordinate_to_cell_key(self.cursor_coordinate).row_key + 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) -> R | None: - key = self.selected_key - return None if key is None else self.records.get(key) + def selected(self) -> TableSelection[R]: + if not self.row_count: + return EmptyTableSelection() + key = self.coordinate_to_cell_key(self.cursor_coordinate).row_key + return SelectedTableRecord(self.records[key]) def reconcile_source(self) -> None: - selected, previous = self.selected_key, self.selected + previous = self.selected offset = self.scroll_offset entities = {DisclosedValue.of(record).sequence_key(): record for record in self.source.records} if len(entities) != len(self.source.records): raise ValueError("A declared table requires unique record identities") - keys = {entity: self.entity_keys.get(entity, RowKey()) for entity in entities} - selected = keys.get(self.preferred_entity(), selected) - incoming = {keys[entity]: record for entity, record in entities.items()} + selected = TableSelection[R].from_records( + entities.values(), 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(incoming.values(), layout.texts): + 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(row.key for row in self.ordered_rows) == tuple(incoming) - and tuple(tuple(self.get_row(key)) for key in incoming) == tuple(rows)) - self.records, self.entity_keys = incoming, keys - if same_rows and not columns_changed: - return - # Public clear/add owns auto-height remeasurement and native row ordering. - # Retain row keys, cursor and scroll rather than mutating Textual's caches. + 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): - 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) - for key, cells in zip(incoming, rows): - self.add_row(*cells, key=key, height=None) - if selected in incoming: - self.move_cursor(row=self.get_row_index(selected), animate=False, scroll=False) - elif incoming: - self.move_cursor(row=min(self.cursor_row, len(incoming) - 1), animate=False, scroll=False) - self.scroll_to(x=offset.x, y=offset.y, animate=False, force=True) - if not same_rows and self.selected != previous: + if same_rows and not columns_changed: + # The actual record can change while every painted cell stays equal. + self.records = {self.entity_keys[entity]: record for entity, record in entities.items()} + else: + # Anonymous keys are allocated by add_row, never invented beside it. + # Rebuild both maps from the returned keys, retaining semantic identity. + 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, R] = {} + keys: dict[DisclosureKey, RowKey] = {} + for (entity, record), cells in zip(entities.items(), rows): + native = self.add_row( + *cells, height=None, + key=self.entity_keys[entity].value if entity in self.entity_keys else None, + ) + records[native], keys[entity] = record, native + self.records, self.entity_keys = records, keys + self.scroll_to(x=offset.x, y=offset.y, animate=False, force=True) + for record in selected.records: + key = self.entity_keys[DisclosedValue.of(record).sequence_key()] + self.move_cursor(row=self.get_row_index(key), animate=False, scroll=False) + if self.selected != previous: self.selection_changed() def on_resize(self) -> None: @@ -99,11 +152,11 @@ def on_resize(self) -> None: def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: event.stop() - self.selection_changed() + self.selection_highlighted() def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: event.stop() - self.selection_changed() + self.selection_highlighted() def cursor_up(self) -> None: DataTable.action_cursor_up(self) diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py new file mode 100644 index 000000000..2327e790d --- /dev/null +++ b/tests/textual/test_commander_page.py @@ -0,0 +1,232 @@ +"""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, +) +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, 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 CommanderAlarm, CommanderFrameView, CommanderPage, CommanderTable +from toad.widgets.declared_disclosure import ( + DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, +) +from toad.widgets.declared_table import EmptyTableSelection + + +def published_hud() -> CommanderHud: + unit = CommanderUnit( + RecordedThreadIncarnation("hud-worker", 2.0), Detached, + PendingClock(1.0, 1.0), (), 1, (), (), + ) + return CommanderHud( + RecordedThreadIncarnation("claude-code", 1.0), (unit,), (), + OverloadDegradation.from_orders((), 0, 0), + (RecordedThreadIncarnation("fork-hud", 2.0),), + 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()) + unreadable = json.dumps({**written, "field_from_a_newer_build": []}) + frame.write_text(unreadable) + async with workspace.app() as ui: + app, pilot = ui.textual + page = CommanderPage() + await app.push_screen(page) + notice = page.query_one("#hud-error", Static) + await ui.until(lambda: notice.display, why="the unreadable frame to reach the page") + assert "field_from_a_newer_build" in str(notice.render()) + rows = page.query_one("#hud-rows") + assert [str(row.render()) for row in rows.query(Static)] == [ + "Waiting for the commander heartbeat.", + ] + + CommanderFrame.replace_all(workspace.comms.root, (hud,)) + await ui.until_shows("Commander claude-code") + assert not notice.display + last_good = tuple(rows.children) + view = rows.query_one(CommanderFrameView) + 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.records == (unit,), why="the actual unit to acquire a native row") + table.focus() + await pilot.press("enter") + await ui.until_shows(Detached.action) + # 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" + strip = str(view.status.render()) + assert "Load: 0" in strip and "Forks: 1" in strip + assert hud.usage.line() in strip + assert view.selected.records == view.details.selection.records == (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) + identical = replace(hud, units=(replace(unit, clock=PendingClock(1.0, 2.0)),)) + view.present(identical) + await ui.step() + 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(lambda: view.details.selection.records == repaired.units, + 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(CommanderTable) is table and table.has_focus + assert table.selected.records == repaired.units + 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(), + access=app.coordination_access, + ) + failed.observe_commander(ObserveCommanderHud()) + await ui.until(lambda: notice.display, why="the observer failure notice") + assert str(notice.render()) == "Commander rows unavailable: observer process exited" + assert tuple(rows.children) == recovered_rows + await pilot.press("escape") + await ui.step() + assert app.screen is not page + + +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() + await app.push_screen(page) + await ui.step() + app.coordination_access.events.publish(CommanderHudChanged(())) + 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.records == (second,), why="the highlighted second record") + semantic = view.selected.key + before_keys = tuple(table.entity_keys.values()) + assert set(table.records) == set(before_keys) == {row.key for row in table.ordered_rows} + + 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.records == (second,) + assert set(table.records) == set(table.entity_keys.values()) == {row.key for row in table.ordered_rows} + 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() + 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.records == (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.records == (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.records == (moved,), why="the original selection to move between groups") + await ui.until(lambda: any(native.selected.records == (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.records == (moved,) + for native in view.query(CommanderTable)), + why="native focus to follow its selected semantic record") + assert view.details.selection.records == (moved,) + assert all(set(native.records) == set(native.entity_keys.values()) == {row.key for row in native.ordered_rows} + 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.records == (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 = DisclosedValue.of(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.source.summary() + assert disclosed.source.summary(20) in str(disclosure.title) + assert DisclosedValue.of(Detached).source.summary() == Detached.declared_name + assert not str(disclosure.body.render()).startswith(" Date: Thu, 29 Oct 2026 15:35:56 -0400 Subject: [PATCH 11/15] Own selection dispatch and prepare native identities at source arrival --- src/toad/screens/commander.py | 126 +++++++++++++----------- src/toad/widgets/declared_disclosure.py | 8 +- src/toad/widgets/declared_table.py | 107 +++++++++++--------- tests/textual/test_commander_page.py | 49 +++++---- 4 files changed, 163 insertions(+), 127 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index b3d228ee4..43571976d 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -8,7 +8,7 @@ from agent_comms.commander_hud import CommanderHud, CommanderUnit, HudRecord, HudSection from agent_comms.coordination_errors import DeferredRead from agent_comms.disclosure_text import ( - AccentTone, ErrorTone, HudTone, SuccessTone, ViewTable, WarningTone, + AccentTone, ErrorTone, HudTone, MutedTone, SuccessTone, ViewTable, WarningTone, ) from agent_comms.errors import RelationViolationError, RouteChangedError, ToolRefusedError from agent_comms.task_attachments.commander_pings import AlarmGroup, AlarmSection @@ -34,9 +34,11 @@ from toad.screens.modal import DismissibleScreen from toad.widget_actions import DismissAction from toad.widgets.declared_disclosure import ( - DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, + DeclaredDisclosure, DisclosureEntry, DisclosureKey, DisclosedValue, RootKey, +) +from toad.widgets.declared_table import ( + DeclaredTable, EmptyTableSelection, SelectedTableRecord, SelectionView, TableSelection, ) -from toad.widgets.declared_table import DeclaredTable, EmptyTableSelection, TableSelection from toad.widgets.sidebar_tree import KeyedRows @@ -47,9 +49,9 @@ class UnitTone(MroDispatch): def of(cls, tone: type[HudTone]) -> type[ThemeColour]: return cls.handler_for(tone)(tone) - @handles(HudTone) + @handles(MutedTone) @staticmethod - def ordinary(tone: type[HudTone]) -> type[ThemeColour]: + def ordinary(tone: type[MutedTone]) -> type[ThemeColour]: return ForegroundMutedColour @handles(WarningTone) @@ -125,7 +127,9 @@ def create_row(self, key: type[HudRecord]) -> CommanderTable: return CommanderTable(self.sources[key]) def update_row(self, key: type[HudRecord], row: CommanderTable) -> None: - row.present(self.sources[key]) + source = self.sources[key] + if row.source is not source: + row.present(source) def present(self, section: HudSection) -> None: self.section = section @@ -160,13 +164,13 @@ def present(self, section: AlarmSection, now: float) -> None: self.display = True -class CommanderDetails(MroDispatch, ToadNode, VerticalGroup): +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.selection: TableSelection[HudRecord] = EmptyTableSelection() - self.status = Static("No records match the filter.", markup=False) + self.status = Static("", markup=False) self.original = VerticalGroup() self.original.display = False super().__init__(classes="hud-detail") @@ -180,12 +184,23 @@ def present(self, selection: TableSelection[HudRecord]) -> None: if self.selection == selection: return self.selection = selection - self.read_detail(selection) + selection.present_to(self) + + def on_mount(self) -> None: + self.selection.present_to(self) + + 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) + self.selection.present_to(self) @handles(HudRecord) async def record_detail(self, card: HudRecord) -> object: @@ -200,29 +215,28 @@ async def unit_detail(self, card: CommanderUnit) -> object: )) @work(exclusive=True, group="commander-detail") - async def read_detail(self, selection: TableSelection[HudRecord]) -> None: + async def read_detail(self, selection: SelectedTableRecord[HudRecord]) -> None: self.original.display = False - self.status.update("No records match the filter.") - for card in selection.records: - label = DisclosedValue.of(card).source.summary() - self.status.update(f"Reading original records for {label}…") - try: - 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.selection != selection: - return - entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) - for view in self.original.query_children(DeclaredDisclosure): - view.present(entry) - break - else: - self.original.mount(DeclaredDisclosure(entry)) - self.original.display = True - self.status.update("Original records; unit activity is bounded by Core.") + card = selection.record + label = DisclosedValue.of(card).source.summary() + self.status.update(f"Reading original records for {label}…") + try: + 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.selection != selection: + return + entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) + for view in self.original.query_children(DeclaredDisclosure): + view.present(entry) + break + else: + 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") @@ -244,9 +258,8 @@ class CommanderFrameView(KeyedRows[type[HudGroup]], ToadNode, VerticalGroup): def __init__(self, hud: CommanderHud) -> None: self.hud = hud - self.selected = TableSelection[HudRecord].from_records( - record for section in hud.groups() for record in section.records - ) + 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.groups = VerticalGroup() @@ -267,15 +280,18 @@ def compose(self) -> ComposeResult: yield from self.alarms.values() yield self.details - @property - def sections(self) -> dict[type[HudGroup], HudSection]: + def filtered_sections(self) -> dict[type[HudGroup], HudSection]: text = self.filter.value.casefold() - return {section.group: HudSection(section.group, tuple( - replace(table, 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())) - for table in section.tables - )) for section in self.hud.groups()} + 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: @@ -286,10 +302,10 @@ def rows(self) -> dict[type[HudGroup], CommanderGroup]: return {view.section.group: view for view in self.groups.query_children(CommanderGroup)} def create_row(self, key: type[HudGroup]) -> CommanderGroup: - return CommanderGroup(self.sections[key]) + return CommanderGroup(self.shown[key]) def update_row(self, key: type[HudGroup], row: CommanderGroup) -> None: - row.present(self.sections[key]) + row.present(self.shown[key]) @on(Input.Changed) @on(Checkbox.Changed) @@ -299,10 +315,7 @@ def filter_changed(self) -> None: @on(CommanderTable.Selected) def highlight_record(self, event: CommanderTable.Selected) -> None: event.stop() - self.selected = TableSelection[HudRecord].from_records( - (record for section in self.sections.values() for record in section.records), - event.selection.key, - ) + self.selected = event.selection self.details.present(self.selected) def present(self, hud: CommanderHud) -> None: @@ -311,8 +324,8 @@ def present(self, hud: CommanderHud) -> None: def focus_selection(self) -> None: for table in self.query(CommanderTable): - if any(DisclosedValue.of(record).sequence_key() == self.selected.key - for record in table.source.records): + # Focus needs the prepared semantic record, not allocated native row keys. + if self.selected.key in table.entities: table.focus(scroll_visible=False) break @@ -325,13 +338,12 @@ def render_frame(self) -> None: view.display = False for section in self.hud.alarms: self.alarms[section.group].present(section, now) - sections = self.sections - self.selected = TableSelection[HudRecord].from_records( - (record for section in sections.values() for record in section.records), - self.selected.key, - ) + self.shown = self.filtered_sections() + records = {DisclosedValue.of(record).sequence_key(): record + for section in self.shown.values() for record in section.records} + self.selected = TableSelection[HudRecord].from_records(records, self.selected.key) self.details.present(self.selected) - keys = tuple(sections) + keys = tuple(self.shown) self.reconcile_keys(keys, changed=frozenset(keys)) if focused: self.call_after_refresh(self.focus_selection) diff --git a/src/toad/widgets/declared_disclosure.py b/src/toad/widgets/declared_disclosure.py index 170eafc6c..4b82f6ed7 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -11,7 +11,6 @@ DisclosureText, IdentifiedRecordText, MappingText, RecordText, SequenceText, ) -from agent_comms.field_codec import FieldCodec from textual import events from textual.containers import VerticalGroup from textual.content import Content @@ -111,7 +110,10 @@ def show(self, view: DeclaredDisclosure) -> None: view.reconcile_entries(entries) +@dataclass(frozen=True) class RecordValue(StructuredValue): + source: RecordText + def children(self) -> tuple[DisclosureEntry, ...]: return tuple( DisclosureEntry( @@ -119,7 +121,7 @@ def children(self) -> tuple[DisclosureEntry, ...]: plan.label, DisclosedValue.of(plan.current(self.source.value)), ) - for plan in FieldCodec.field_plans(type(self.source.value)) if plan.records + for plan in self.source.fields() ) @@ -212,7 +214,7 @@ def reconcile_entries(self, entries: tuple[DisclosureEntry, ...]) -> None: def fit_title(self) -> None: width = max(1, self.size.width - 2) - title = Content(f"{self.entry.label[:width]}: {self.entry.value.source.summary(width)}") + title = Content(f"{self.entry.label}: {self.entry.value.source.summary()}".partition("\n")[0]) self.title = title.truncate(width, ellipsis=True) def on_resize(self, event: events.Resize) -> None: diff --git a/src/toad/widgets/declared_table.py b/src/toad/widgets/declared_table.py index 2fc37b315..215809467 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -3,8 +3,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Iterable -from dataclasses import dataclass +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType from agent_comms.disclosure_text import ViewTable from textual.content import Content @@ -17,60 +18,79 @@ from toad.widgets.declared_disclosure import AnonymousKey, DisclosureKey, DisclosedValue -@dataclass(frozen=True) -class TableSelection[R](ABC): - """Absent or present original record, never a native row key or a reader request.""" +class SelectionView[R]: + """A native selection consumer implements both states without another wrapper.""" - @property @abstractmethod - def records(self) -> tuple[R, ...]: ... + 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: Iterable[R], *preferred: DisclosureKey) -> TableSelection[R]: - """Resolve semantic preferences against actual records, otherwise take the first.""" - choices = tuple(SelectedTableRecord(record) for record in records) - empty: TableSelection[R] = EmptyTableSelection() - return next((choice for key in preferred for choice in choices if choice.key == key), - next(iter(choices), empty)) + 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]): - @property - def records(self) -> tuple[R, ...]: - return () + def present_to(self, view: SelectionView[R]) -> None: + view.clear_selection() -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class SelectedTableRecord[R](TableSelection[R]): + key: DisclosureKey = field() record: R - @property - def records(self) -> tuple[R, ...]: - return (self.record,) + def present_to(self, view: SelectionView[R]) -> None: + view.show_selection(self.key, self.record) - @property - def key(self) -> DisclosureKey: - return DisclosedValue.of(self.record).sequence_key() + 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], **kwargs) -> None: - super().__init__(cursor_type="row", show_row_labels=False, **kwargs) - self.source = source - self.records: dict[RowKey, R] = {} + 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.call_after_refresh(self.reconcile_source) + self.present(source) def present(self, source: ViewTable[R]) -> None: self.source = source - # The native message pump admits this after mount/layout, including updates + self.entities = MappingProxyType({DisclosedValue.of(record).sequence_key(): record + for record in source.records}) + if len(self.entities) != len(source.records): + raise ValueError("A declared table requires unique record identities") + # 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) @@ -93,17 +113,14 @@ def selected(self) -> TableSelection[R]: if not self.row_count: return EmptyTableSelection() key = self.coordinate_to_cell_key(self.cursor_coordinate).row_key - return SelectedTableRecord(self.records[key]) + return self.records[key] def reconcile_source(self) -> None: previous = self.selected offset = self.scroll_offset - entities = {DisclosedValue.of(record).sequence_key(): record - for record in self.source.records} - if len(entities) != len(self.source.records): - raise ValueError("A declared table requires unique record identities") + entities = self.entities selected = TableSelection[R].from_records( - entities.values(), self.preferred_selection().key, previous.key, + 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)) @@ -123,27 +140,23 @@ def reconcile_source(self) -> None: 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]: record for entity, record in entities.items()} + self.records = {self.entity_keys[entity]: SelectedTableRecord(entity, record) + for entity, record in entities.items()} else: - # Anonymous keys are allocated by add_row, never invented beside it. - # Rebuild both maps from the returned keys, retaining semantic identity. + # 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, R] = {} + 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, - key=self.entity_keys[entity].value if entity in self.entity_keys else None, - ) - records[native], keys[entity] = record, native + 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) - for record in selected.records: - key = self.entity_keys[DisclosedValue.of(record).sequence_key()] - self.move_cursor(row=self.get_row_index(key), animate=False, scroll=False) + selected.place_cursor(self) if self.selected != previous: self.selection_changed() diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index 2327e790d..23127a45d 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -5,7 +5,7 @@ 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.recorded_time import RecordedEventTime from agent_comms.standing_rules import ServicePassFailing @@ -23,7 +23,16 @@ from toad.widgets.declared_disclosure import ( DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, ) -from toad.widgets.declared_table import EmptyTableSelection +from toad.widgets.declared_table import EmptyTableSelection, SelectedTableRecord + + +def selected(record: HudRecord) -> SelectedTableRecord[HudRecord]: + return SelectedTableRecord(DisclosedValue.of(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())) def published_hud() -> CommanderHud: @@ -66,7 +75,7 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace: view = rows.query_one(CommanderFrameView) 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.records == (unit,), why="the actual unit to acquire a native row") + 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_shows(Detached.action) @@ -77,7 +86,7 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace: strip = str(view.status.render()) assert "Load: 0" in strip and "Forks: 1" in strip assert hud.usage.line() in strip - assert view.selected.records == view.details.selection.records == (unit,) + 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] = [] @@ -98,7 +107,7 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace: forks=(RecordedThreadIncarnation("recovered", 3.0),), ) CommanderFrame.replace_all(workspace.comms.root, (repaired,)) - await ui.until(lambda: view.details.selection.records == repaired.units, + 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") @@ -106,7 +115,7 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace: assert not notice.display assert rows.query_one(CommanderFrameView) is view assert view.query_one(CommanderTable) is table and table.has_focus - assert table.selected.records == repaired.units + assert table.selected == selected(repaired.units[0]) 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. @@ -155,16 +164,16 @@ async def test_selection_survives_filter_clear_refill_group_move_and_resize(work 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.records == (second,), why="the highlighted second record") + 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) == {row.key for row in table.ordered_rows} + 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.records == (second,) - assert set(table.records) == set(table.entity_keys.values()) == {row.key for row in table.ordered_rows} + 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") @@ -174,31 +183,31 @@ async def test_selection_survives_filter_clear_refill_group_move_and_resize(work 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.records == (first,), why="clear-and-refill to choose the first visible record") + 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.records == (second,), why="the second semantic identity to be selected again") + 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.records == (moved,), why="the original selection to move between groups") - await ui.until(lambda: any(native.selected.records == (moved,) for native in view.query(CommanderTable)), + 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.records == (moved,) + 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.records == (moved,) - assert all(set(native.records) == set(native.entity_keys.values()) == {row.key for row in native.ordered_rows} - for native in view.query(CommanderTable)) + 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.records == (first,), why="a later frame to refill the original records") + 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: @@ -212,7 +221,7 @@ async def test_core_summaries_and_alarm_ages_reach_native_disclosures(workspace: disclosure.collapsed = False await ui.until(lambda: disclosure.body.display, why="the scalar/class/family body to expand") assert str(disclosure.body.render()) == disclosed.source.summary() - assert disclosed.source.summary(20) in str(disclosure.title) + assert disclosed.source.summary() in str(disclosure.title) assert DisclosedValue.of(Detached).source.summary() == Detached.declared_name assert not str(disclosure.body.render()).startswith(" Date: Thu, 29 Oct 2026 18:03:31 -0400 Subject: [PATCH 12/15] Reuse equal frozen commander tables and cached HUD groups --- src/toad/screens/commander.py | 4 ++-- tests/textual/test_commander_page.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 43571976d..e1840f597 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -128,7 +128,7 @@ def create_row(self, key: type[HudRecord]) -> CommanderTable: def update_row(self, key: type[HudRecord], row: CommanderTable) -> None: source = self.sources[key] - if row.source is not source: + if row.source != source: row.present(source) def present(self, section: HudSection) -> None: @@ -283,7 +283,7 @@ def compose(self) -> ComposeResult: def filtered_sections(self) -> dict[type[HudGroup], HudSection]: text = self.filter.value.casefold() shown: dict[type[HudGroup], HudSection] = {} - for section in self.hud.groups(): + 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) diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index 23127a45d..b1a9631a9 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -19,7 +19,9 @@ from toad.comms_root import FailedObservation, UnselectedRoute from toad.core.events import CommanderHudChanged -from toad.screens.commander import CommanderAlarm, CommanderFrameView, CommanderPage, CommanderTable +from toad.screens.commander import ( + CommanderAlarm, CommanderFrameView, CommanderGroup, CommanderPage, CommanderTable, +) from toad.widgets.declared_disclosure import ( DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, ) @@ -91,9 +93,21 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace: # 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) @@ -116,6 +130,8 @@ async def test_commander_page_survives_unreadable_frames_and_recovers(workspace: assert rows.query_one(CommanderFrameView) is view 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. From a131a9dc4107d73d2d751813677a4053733ab4a4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:28:06 -0400 Subject: [PATCH 13/15] Borrow Core disclosure children and identities; delete native reconstruction --- src/toad/screens/commander.py | 17 +-- src/toad/widgets/declared_disclosure.py | 191 ++++-------------------- src/toad/widgets/declared_table.py | 10 +- tests/textual/test_commander_page.py | 35 ++++- 4 files changed, 62 insertions(+), 191 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index e1840f597..5fd99a3fe 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -8,7 +8,8 @@ from agent_comms.commander_hud import CommanderHud, CommanderUnit, HudRecord, HudSection from agent_comms.coordination_errors import DeferredRead from agent_comms.disclosure_text import ( - AccentTone, ErrorTone, HudTone, MutedTone, SuccessTone, ViewTable, WarningTone, + 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 @@ -33,9 +34,7 @@ from toad.core_event_carrier import CoreEventReceiver from toad.screens.modal import DismissibleScreen from toad.widget_actions import DismissAction -from toad.widgets.declared_disclosure import ( - DeclaredDisclosure, DisclosureEntry, DisclosureKey, DisclosedValue, RootKey, -) +from toad.widgets.declared_disclosure import DeclaredDisclosure from toad.widgets.declared_table import ( DeclaredTable, EmptyTableSelection, SelectedTableRecord, SelectionView, TableSelection, ) @@ -218,7 +217,7 @@ async def unit_detail(self, card: CommanderUnit) -> object: async def read_detail(self, selection: SelectedTableRecord[HudRecord]) -> None: self.original.display = False card = selection.record - label = DisclosedValue.of(card).source.summary() + label = DisclosureText.of(card).summary self.status.update(f"Reading original records for {label}…") try: detail = await type(self).handler_for(type(card))(self, card) @@ -229,7 +228,7 @@ async def read_detail(self, selection: SelectedTableRecord[HudRecord]) -> None: return if not self.is_attached or self.selection != selection: return - entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosedValue.of(detail)) + entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosureText.of(detail)) for view in self.original.query_children(DeclaredDisclosure): view.present(entry) break @@ -325,7 +324,7 @@ def present(self, hud: CommanderHud) -> None: 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.entities: + if self.selected.key in table.source.entities: table.focus(scroll_visible=False) break @@ -339,8 +338,8 @@ def render_frame(self) -> None: for section in self.hud.alarms: self.alarms[section.group].present(section, now) self.shown = self.filtered_sections() - records = {DisclosedValue.of(record).sequence_key(): record - for section in self.shown.values() for record in section.records} + 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) diff --git a/src/toad/widgets/declared_disclosure.py b/src/toad/widgets/declared_disclosure.py index 4b82f6ed7..32f5b5af1 100644 --- a/src/toad/widgets/declared_disclosure.py +++ b/src/toad/widgets/declared_disclosure.py @@ -1,16 +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 abc import ABC, abstractmethod -from dataclasses import dataclass -from functools import singledispatchmethod -from typing import Hashable - -from agent_comms.disclosure_text import ( - DisclosureText, IdentifiedRecordText, - MappingText, RecordText, SequenceText, -) +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 @@ -19,158 +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.""" - - source: DisclosureText - - @classmethod - def of(cls, value: object) -> DisclosedValue: - return cls.from_text(DisclosureText.of(value)) - - @singledispatchmethod - @classmethod - def from_text(cls, source: DisclosureText) -> DisclosedValue: - """Adapt Core's decoded shape; never rediscover the original value's shape.""" - return cls(source) - - @from_text.register(RecordText) - @classmethod - def _record(cls, source: RecordText) -> DisclosedValue: - return RecordValue(source) - - @from_text.register(IdentifiedRecordText) - @classmethod - def _identified(cls, source: IdentifiedRecordText) -> DisclosedValue: - return IdentifiedRecordValue(source) - - @from_text.register(MappingText) - @classmethod - def _mapping(cls, source: MappingText) -> DisclosedValue: - return MappingValue(source) - - @from_text.register(SequenceText) - @classmethod - def _sequence(cls, source: SequenceText) -> DisclosedValue: - return SequenceValue(source) - - def sequence_key(self) -> DisclosureKey: - return AnonymousKey() - - def identity_part(self) -> object: - return self.source.value - - def show(self, view: DeclaredDisclosure) -> None: - view.body.display = True - view.members.display = False - view.body.update(self.source.summary()) - - -class StructuredValue(DisclosedValue, ABC): - @abstractmethod - def children(self) -> tuple[DisclosureEntry, ...]: ... - - def show(self, view: DeclaredDisclosure) -> None: - view.body.display = False - view.members.display = True - entries = self.children() - view.reconcile_entries(entries) - - -@dataclass(frozen=True) -class RecordValue(StructuredValue): - source: RecordText - - def children(self) -> tuple[DisclosureEntry, ...]: - return tuple( - DisclosureEntry( - FieldKey(type(self.source.value), plan.name), - plan.label, - DisclosedValue.of(plan.current(self.source.value)), - ) - for plan in self.source.fields() - ) - - -@dataclass(frozen=True) -class IdentifiedRecordValue(RecordValue): - source: IdentifiedRecordText - - def sequence_key(self) -> EntityKey: - return EntityKey(type(self.source.value), tuple( - DisclosedValue.of(plan.current(self.source.value)).identity_part() - for plan in self.source.identity - )) - - def identity_part(self) -> EntityKey: - return self.sequence_key() - - -@dataclass(frozen=True) -class SequenceValue(StructuredValue): - source: SequenceText - - def children(self) -> tuple[DisclosureEntry, ...]: - entries = [] - for index, value in enumerate(self.source.value): - disclosed = DisclosedValue.of(value) - entries.append(DisclosureEntry(disclosed.sequence_key(), str(index + 1), disclosed)) - return tuple(entries) - - -@dataclass(frozen=True) -class MappingValue(StructuredValue): - source: MappingText - - def children(self) -> tuple[DisclosureEntry, ...]: - return tuple( - DisclosureEntry(MappingKey(key), str(key), DisclosedValue.of(value)) - for key, value in self.source.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; } @@ -202,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}") @@ -212,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}: {self.entry.value.source.summary()}".partition("\n")[0]) + 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: @@ -224,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 index 215809467..8c76d636c 100644 --- a/src/toad/widgets/declared_table.py +++ b/src/toad/widgets/declared_table.py @@ -5,9 +5,8 @@ from abc import ABC, abstractmethod from collections.abc import Mapping from dataclasses import dataclass, field -from types import MappingProxyType -from agent_comms.disclosure_text import ViewTable +from agent_comms.disclosure_text import AnonymousKey, DisclosureKey, ViewTable from textual.content import Content from textual.style import Style from textual.widgets import DataTable @@ -15,7 +14,6 @@ from ui_theme.scheme import ForegroundColour, ThemeColour from toad.widget_actions import MovesCursor, OpensSelected -from toad.widgets.declared_disclosure import AnonymousKey, DisclosureKey, DisclosedValue class SelectionView[R]: @@ -86,10 +84,6 @@ def __init__(self, source: ViewTable[R]) -> None: def present(self, source: ViewTable[R]) -> None: self.source = source - self.entities = MappingProxyType({DisclosedValue.of(record).sequence_key(): record - for record in source.records}) - if len(self.entities) != len(source.records): - raise ValueError("A declared table requires unique record identities") # 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) @@ -118,7 +112,7 @@ def selected(self) -> TableSelection[R]: def reconcile_source(self) -> None: previous = self.selected offset = self.scroll_offset - entities = self.entities + entities = self.source.entities selected = TableSelection[R].from_records( entities, self.preferred_selection().key, previous.key, ) diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index b1a9631a9..0fb9fd9fe 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -7,6 +7,7 @@ from agent_comms.commander_hud import ( 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 @@ -22,14 +23,12 @@ from toad.screens.commander import ( CommanderAlarm, CommanderFrameView, CommanderGroup, CommanderPage, CommanderTable, ) -from toad.widgets.declared_disclosure import ( - DeclaredDisclosure, DisclosureEntry, DisclosedValue, RootKey, -) +from toad.widgets.declared_disclosure import DeclaredDisclosure from toad.widgets.declared_table import EmptyTableSelection, SelectedTableRecord def selected(record: HudRecord) -> SelectedTableRecord[HudRecord]: - return SelectedTableRecord(DisclosedValue.of(record).sequence_key(), record) + return SelectedTableRecord(DisclosureText.of(record).sequence_key, record) def row_maps_cohere(table: CommanderTable) -> bool: @@ -231,16 +230,36 @@ async def test_core_summaries_and_alarm_ages_reach_native_disclosures(workspace: async with workspace.app() as ui: app, _pilot = ui.textual for value in (hud.units[0].strikes, CommanderUnit, Detached): - disclosed = DisclosedValue.of(value) + disclosed = DisclosureText.of(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.source.summary() - assert disclosed.source.summary() in str(disclosure.title) - assert DisclosedValue.of(Detached).source.summary() == Detached.declared_name + assert str(disclosure.body.render()) == disclosed.summary + assert disclosed.summary in str(disclosure.title) + assert DisclosureText.of(Detached).summary == Detached.declared_name assert not str(disclosure.body.render()).startswith(" Date: Fri, 30 Oct 2026 01:28:59 -0400 Subject: [PATCH 14/15] Use declared HUD and service-failure fixture arguments --- tests/textual/test_commander_page.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index 0fb9fd9fe..3980dc500 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -42,10 +42,10 @@ def published_hud() -> CommanderHud: PendingClock(1.0, 1.0), (), 1, (), (), ) return CommanderHud( - RecordedThreadIncarnation("claude-code", 1.0), (unit,), (), - OverloadDegradation.from_orders((), 0, 0), - (RecordedThreadIncarnation("fork-hud", 2.0),), - CodexUsage(UnreadableUsage(reason="not requested")), open_pings=(), + 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=(), ) @@ -260,7 +260,8 @@ async def test_core_summaries_and_alarm_ages_reach_native_disclosures(workspace: why="an original detail field to update even when its identity summary is equal") assert original.rows[strikes] is before[strikes] - ping = replace(RulePing.capture(ServicePassFailing("native-alarm", 1000, "original error")), + ping = replace(RulePing.capture(ServicePassFailing( + watch="native-alarm", since=1000, error="original error")), since=RecordedEventTime(1000)) section = AlarmSection(NewAlarms, (ping,)) alarm = CommanderAlarm(NewAlarms) From 53e8554ebdb7fd3c7ba8002cbdeeb8240bd902da Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 02:53:58 -0400 Subject: [PATCH 15/15] Use the inherited Core description factory in commander details --- src/toad/screens/commander.py | 4 ++-- tests/textual/test_commander_page.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 5fd99a3fe..aa8459ca8 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -217,7 +217,7 @@ async def unit_detail(self, card: CommanderUnit) -> object: async def read_detail(self, selection: SelectedTableRecord[HudRecord]) -> None: self.original.display = False card = selection.record - label = DisclosureText.of(card).summary + label = DisclosureText.describe(card).summary self.status.update(f"Reading original records for {label}…") try: detail = await type(self).handler_for(type(card))(self, card) @@ -228,7 +228,7 @@ async def read_detail(self, selection: SelectedTableRecord[HudRecord]) -> None: return if not self.is_attached or self.selection != selection: return - entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosureText.of(detail)) + entry = DisclosureEntry(RootKey(type(detail)), "Original details", DisclosureText.describe(detail)) for view in self.original.query_children(DeclaredDisclosure): view.present(entry) break diff --git a/tests/textual/test_commander_page.py b/tests/textual/test_commander_page.py index 3980dc500..d52337048 100644 --- a/tests/textual/test_commander_page.py +++ b/tests/textual/test_commander_page.py @@ -28,7 +28,7 @@ def selected(record: HudRecord) -> SelectedTableRecord[HudRecord]: - return SelectedTableRecord(DisclosureText.of(record).sequence_key, record) + return SelectedTableRecord(DisclosureText.describe(record).sequence_key, record) def row_maps_cohere(table: CommanderTable) -> bool: @@ -230,17 +230,17 @@ async def test_core_summaries_and_alarm_ages_reach_native_disclosures(workspace: async with workspace.app() as ui: app, _pilot = ui.textual for value in (hud.units[0].strikes, CommanderUnit, Detached): - disclosed = DisclosureText.of(value) + 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.of(Detached).summary == Detached.declared_name + assert DisclosureText.describe(Detached).summary == Detached.declared_name assert not str(disclosure.body.render()).startswith("