From 948a5d5e07673e601c2a8fe07e119e01c8fc04a6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:15:55 -0400 Subject: [PATCH 01/22] Keep missing command inputs unset until the creator supplies them --- src/toad/forms/editors.py | 29 ++++++++++++++++++------ src/toad/widgets/comms_command_dialog.py | 29 +++++++++++++++++------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index a40dc41b0..625e183cc 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -79,8 +79,8 @@ def paint(self) -> None: """Paint the control from the state.""" raise NotImplementedError - def finish(self) -> None: - """Send an edit the user has not finished yet. Most controls have none.""" + def finish(self, *, explicit: bool = False) -> None: + """Commit pending input; explicit also confirms an unchanged displayed value.""" def value_text(self, value: object) -> str: """How a value of this field reads.""" @@ -115,6 +115,10 @@ def changed(self, message: Checkbox.Changed) -> None: message.stop() self.send(message.value) + def finish(self, *, explicit: bool = False) -> None: + if explicit: + self.send(self.value) + def paint(self) -> None: with self.prevent(Checkbox.Changed): self.value = bool(self.field.value) @@ -152,8 +156,8 @@ def finished(self, message: Input.Blurred | Input.Submitted) -> None: message.stop() self.finish() - def finish(self) -> None: - if self.value == self.value_text(self.field.value): + def finish(self, *, explicit: bool = False) -> None: + if not explicit and self.value == self.value_text(self.field.value): return try: value = self.read(self.value) @@ -198,6 +202,10 @@ def changed(self, message: TextArea.Changed) -> None: message.stop() self.send(self.text) + def finish(self, *, explicit: bool = False) -> None: + if explicit: + self.send(self.text) + def paint(self) -> None: value = str(self.field.value or "") if self.text != value: @@ -247,6 +255,10 @@ def changed(self, message: Select.Changed) -> None: if message.value is not Select.NULL: self.send(message.value) + def finish(self, *, explicit: bool = False) -> None: + if explicit and self.value is not Select.NULL: + self.send(self.value) + def paint(self) -> None: value = self.field.value with self.prevent(Select.Changed): @@ -304,12 +316,15 @@ def member_feedback(self, message: ValueEditor.Feedback) -> None: def held_changed(self, message: Checkbox.Changed) -> None: message.stop() self.member.display = message.value - if not message.value: + if message.value: + self.member.finish(explicit=True) + else: self.member.set_reactive(ValueEditor.feedback, "") self.send(None) - def finish(self) -> None: - self.member.finish() + def finish(self, *, explicit: bool = False) -> None: + if self.holds_value.value: + self.member.finish(explicit=explicit) def value_text(self, value: object) -> str: return "not set" if value is None else self.member.value_text(value) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 8f72beca7..f046dcd52 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -6,6 +6,7 @@ from typing import Annotated from agent_comms.cli_command import TargetAction +from agent_comms.cli_failures import CLI_FAILURES from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption from agent_comms.field_codec import compile_decoder @@ -165,7 +166,10 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) if parameter.multiline: hints.append(Multiline()) - declarations.append((parameter.name, Annotated[compile_decoder(parameter.annotation).editor_annotation, *hints], + annotation = compile_decoder(parameter.annotation).editor_annotation + if parameter.required and not parameter.supplied: + annotation = annotation | type(None) + declarations.append((parameter.name, Annotated[annotation, *hints], field(default=parameter.editor_default))) self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) self.form = InlineFormView(self.state) @@ -202,13 +206,19 @@ async def apply_clicked(self, event: Button.Pressed): def arguments(self): - return {name: self.state.get_resolved_value(name) for name in self.form.values} + return {parameter.name: value for parameter in self.definition.editable_fields + if (value := self.state.get_resolved_value(parameter.name)) is not None + or not parameter.required} async def refresh_catalogs(self, arguments): self.query_one('#command-apply', Button).disabled = True - edited = self.definition.edited(arguments) - fields = await self.app.preparation.run_thread( - lambda: asyncio.run(edited.prepare_fields())) + try: + fields = await self.app.preparation.run_thread( + lambda: asyncio.run(self.definition.prepare_fields(arguments))) + except CLI_FAILURES as error: + if self.is_attached and arguments == self.arguments(): + self.query_one('#command-confirmation', Static).update(str(error)) + return if not self.is_attached or arguments != self.arguments(): return for parameter in fields: @@ -225,15 +235,18 @@ def update_confirmation(self): async def prepare(): def read(): for name, choices in self.choices.items(): - if arguments[name] not in choices.choices(): + if name not in arguments or arguments[name] not in choices.choices(): return f'Choose {name.replace("_", " ")}', False - return self.definition.edited(arguments).confirmation, True + try: + return self.definition.edited(arguments).confirmation, True + except CLI_FAILURES as error: + return str(error), False confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): return self.query_one('#command-confirmation', Static).update(confirmation) confirmed.display = valid and bool(confirmation) self.query_one('#command-apply', Button).disabled = not valid or any( - arguments[name] != choices.parameter.editor_default + name not in arguments or arguments[name] != choices.parameter.editor_default for name, choices in self.choices.items()) self.run_worker(prepare, group='command-confirmation', exclusive=True) From 1b7b76ea77f6ed35f2d2f2a8bf9049fcb30a975b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:18:48 -0400 Subject: [PATCH 02/22] Make explicit value commit an editor operation instead of a mode flag --- src/toad/forms/editors.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 625e183cc..4cf99268a 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -79,8 +79,11 @@ def paint(self) -> None: """Paint the control from the state.""" raise NotImplementedError - def finish(self, *, explicit: bool = False) -> None: - """Commit pending input; explicit also confirms an unchanged displayed value.""" + def finish(self) -> None: + """Send an edit the user has not finished yet. Most controls have none.""" + + def commit(self) -> None: + """Explicitly supply the displayed input, even when it is unchanged.""" def value_text(self, value: object) -> str: """How a value of this field reads.""" @@ -115,9 +118,8 @@ def changed(self, message: Checkbox.Changed) -> None: message.stop() self.send(message.value) - def finish(self, *, explicit: bool = False) -> None: - if explicit: - self.send(self.value) + def commit(self) -> None: + self.send(self.value) def paint(self) -> None: with self.prevent(Checkbox.Changed): @@ -156,9 +158,12 @@ def finished(self, message: Input.Blurred | Input.Submitted) -> None: message.stop() self.finish() - def finish(self, *, explicit: bool = False) -> None: - if not explicit and self.value == self.value_text(self.field.value): + def finish(self) -> None: + if self.value == self.value_text(self.field.value): return + self.commit() + + def commit(self) -> None: try: value = self.read(self.value) except ValueError as error: @@ -202,9 +207,8 @@ def changed(self, message: TextArea.Changed) -> None: message.stop() self.send(self.text) - def finish(self, *, explicit: bool = False) -> None: - if explicit: - self.send(self.text) + def commit(self) -> None: + self.send(self.text) def paint(self) -> None: value = str(self.field.value or "") @@ -255,8 +259,8 @@ def changed(self, message: Select.Changed) -> None: if message.value is not Select.NULL: self.send(message.value) - def finish(self, *, explicit: bool = False) -> None: - if explicit and self.value is not Select.NULL: + def commit(self) -> None: + if self.value is not Select.NULL: self.send(self.value) def paint(self) -> None: @@ -317,14 +321,14 @@ def held_changed(self, message: Checkbox.Changed) -> None: message.stop() self.member.display = message.value if message.value: - self.member.finish(explicit=True) + self.member.commit() else: self.member.set_reactive(ValueEditor.feedback, "") self.send(None) - def finish(self, *, explicit: bool = False) -> None: + def finish(self) -> None: if self.holds_value.value: - self.member.finish(explicit=explicit) + self.member.finish() def value_text(self, value: object) -> str: return "not set" if value is None else self.member.value_text(value) From 9072a564eda20c369468ffd86ef946f7f272b57d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:22:22 -0400 Subject: [PATCH 03/22] Consume declared input presence and require editor commits --- src/toad/forms/editors.py | 8 ++++++++ src/toad/widgets/comms_command_dialog.py | 22 ++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 4cf99268a..a13339e41 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Iterable from enum import Enum from typing import TYPE_CHECKING, ClassVar, Optional @@ -82,6 +83,7 @@ def paint(self) -> None: def finish(self) -> None: """Send an edit the user has not finished yet. Most controls have none.""" + @abstractmethod def commit(self) -> None: """Explicitly supply the displayed input, even when it is unchanged.""" @@ -330,6 +332,12 @@ def finish(self) -> None: if self.holds_value.value: self.member.finish() + def commit(self) -> None: + if self.holds_value.value: + self.member.commit() + else: + self.send(None) + def value_text(self, value: object) -> str: return "not set" if value is None else self.member.value_text(value) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index f046dcd52..131d8927f 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -6,10 +6,9 @@ from typing import Annotated from agent_comms.cli_command import TargetAction -from agent_comms.cli_failures import CLI_FAILURES from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption -from agent_comms.field_codec import compile_decoder +from agent_comms.field_codec import MalformedRecord from agent_comms.login import run_login from objectstate import ObjectState from python_introspect import AnnotationChoices, FieldLabel @@ -54,6 +53,9 @@ def paint(self): self.label = next((choice.name for choice in self.field.metadata(PreparedChoices).parameter.options if choice.value == self.field.value), 'Choose / connect a provider…') + def commit(self) -> None: + self.send(self.field.value) + def on_button_pressed(self, event: Button.Pressed): event.stop() self.choose() @@ -158,7 +160,7 @@ class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyComm def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetField, ...]): super().__init__() self.definition, self.target = definition, target - self.choices = {} + self.choices: dict[str, PreparedChoices] = {} declarations = [] for parameter in fields: hints = [FieldLabel(parameter.description)] @@ -166,10 +168,7 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) if parameter.multiline: hints.append(Multiline()) - annotation = compile_decoder(parameter.annotation).editor_annotation - if parameter.required and not parameter.supplied: - annotation = annotation | type(None) - declarations.append((parameter.name, Annotated[annotation, *hints], + declarations.append((parameter.name, Annotated[parameter.editor_annotation, *hints], field(default=parameter.editor_default))) self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) self.form = InlineFormView(self.state) @@ -206,16 +205,15 @@ async def apply_clicked(self, event: Button.Pressed): def arguments(self): - return {parameter.name: value for parameter in self.definition.editable_fields - if (value := self.state.get_resolved_value(parameter.name)) is not None - or not parameter.required} + return {key: value for parameter in self.definition.editable_fields + for key, value in parameter.argument(self.state.get_resolved_value(parameter.name)).items()} async def refresh_catalogs(self, arguments): self.query_one('#command-apply', Button).disabled = True try: fields = await self.app.preparation.run_thread( lambda: asyncio.run(self.definition.prepare_fields(arguments))) - except CLI_FAILURES as error: + except (ValueError, MalformedRecord) as error: if self.is_attached and arguments == self.arguments(): self.query_one('#command-confirmation', Static).update(str(error)) return @@ -239,7 +237,7 @@ def read(): return f'Choose {name.replace("_", " ")}', False try: return self.definition.edited(arguments).confirmation, True - except CLI_FAILURES as error: + except (ValueError, MalformedRecord) as error: return str(error), False confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): From 20476880c04c5f508e9991940a1e861cad968648 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:45:56 -0400 Subject: [PATCH 04/22] Catch only malformed editor data in command forms --- src/toad/widgets/comms_command_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 131d8927f..d7d9232cf 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -213,7 +213,7 @@ async def refresh_catalogs(self, arguments): try: fields = await self.app.preparation.run_thread( lambda: asyncio.run(self.definition.prepare_fields(arguments))) - except (ValueError, MalformedRecord) as error: + except MalformedRecord as error: if self.is_attached and arguments == self.arguments(): self.query_one('#command-confirmation', Static).update(str(error)) return @@ -237,7 +237,7 @@ def read(): return f'Choose {name.replace("_", " ")}', False try: return self.definition.edited(arguments).confirmation, True - except (ValueError, MalformedRecord) as error: + except MalformedRecord as error: return str(error), False confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): From f8576dda7be5c8e1f58523210f3362828ef2a434 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:21:12 -0400 Subject: [PATCH 05/22] Edit generic union members as cancellable native transactions --- src/toad/forms/declared_views.py | 55 ++++++++- src/toad/forms/editors.py | 142 ++++++++++++++++++++++- src/toad/forms/form_view.py | 8 +- src/toad/widgets/comms_command_dialog.py | 98 +++++++++++++--- 4 files changed, 277 insertions(+), 26 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 394caf1ec..920efc90f 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -2,8 +2,9 @@ from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Iterator -from dataclasses import dataclass +from dataclasses import dataclass, make_dataclass from inspect import cleandoc from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin @@ -17,12 +18,64 @@ resolve_optional, ) from python_introspect.validation import validate_annotation_value +from textual.screen import ModalScreen from toad.forms.kinds import Placeholder Metadata = TypeVar("Metadata") +class MemberDraft(ABC): + """Uncommitted member inputs, never a second committed union value.""" + + state: ObjectState + refusal: ClassVar[type[Exception]] + + @property + @abstractmethod + def declaration(self) -> object: ... + + @abstractmethod + def complete(self) -> object: ... + + +class MemberNotChosen(ValueError): + """The transaction has not selected an original declaration yet.""" + + +class UnchosenMemberDraft(MemberDraft): + refusal = MemberNotChosen + + def __init__(self) -> None: + self.state = ObjectState(make_dataclass('UnchosenMember', [])()) + + @property + def declaration(self) -> object: + return None + + def complete(self) -> object: + raise MemberNotChosen('Choose a member') + + +class UnionMembers(ABC): + """Read-only declared choices; concrete integrations own input construction.""" + + @abstractmethod + def choices(self) -> tuple[object, ...]: ... + + @abstractmethod + def label(self, choice: object) -> str: ... + + @abstractmethod + def selected(self, value: object) -> object: ... + + @abstractmethod + def draft(self, choice: object, value: object) -> MemberDraft: ... + + @abstractmethod + def dialog(self, field: DeclaredField) -> ModalScreen[None]: ... + + class DeclaredViews: """A family of widget classes, each registered under what it shows. diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index a13339e41..a59c2dec4 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -14,7 +14,7 @@ resolve_annotated, ) from python_introspect.validation import validate_annotation_value -from textual import containers +from textual import containers, on, work from textual.app import ComposeResult from textual.message import Message from textual.reactive import var @@ -23,10 +23,11 @@ from textual.css.scalar import Scalar from textual.color import TRANSPARENT from textual.widget import Widget -from textual.widgets import Checkbox, Input, Select, TextArea +from textual.screen import ModalScreen +from textual.widgets import Button, Checkbox, Input, Select, Static, TextArea from textual.widgets._input import InputType -from toad.forms.declared_views import DeclaredField, DeclaredViews, declarations +from toad.forms.declared_views import DeclaredField, DeclaredViews, UnionMembers, declarations from toad.forms.kinds import Multiline from toad.forms.presentation import FormCheckbox, FormInput, FormSelectCurrent, FramedControl from toad.widget_view import WidgetView @@ -347,3 +348,138 @@ def paint(self) -> None: self.holds_value.value = held self.member.display = held self.member.paint() + + +class UnionEditor(ValueEditor, Button): + """Choose and edit a declared member without changing the parent until Apply.""" + + shows = (UnionMembers,) + + @property + def members(self) -> UnionMembers: + metadata = self.field.metadata(UnionMembers) + assert metadata is not None + return metadata + + def bind_control(self) -> None: + self.paint() + + def value_text(self, value: object) -> str: + selected = self.members.selected(value) + return 'Choose a member…' if selected is None else self.members.label(selected) + + def paint(self) -> None: + self.label = self.value_text(self.field.value) + + def commit(self) -> None: + self.send(self.field.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.choose() + + @work(exclusive=True) + async def choose(self) -> None: + dialog = self.members.dialog(self.field) + try: + await self.app.push_screen_wait(dialog) + finally: + if dialog.is_attached: + dialog.dismiss() + if self.is_attached: + self.paint() + + +class UnionMemberDialog(ModalScreen[None]): + """One pending member transaction; its ObjectState never holds a valid parent.""" + + DEFAULT_CSS = ''' + UnionMemberDialog { align: center middle; background: $background 40%; } + UnionMemberDialog #union-box { width: 68; max-width: 95%; height: 90%; + background: $surface; border: solid $primary; padding: 1 2; } + UnionMemberDialog #union-body { height: 1fr; } + UnionMemberDialog #union-buttons { height: 3; } + ''' + + def __init__(self, field: DeclaredField, members: UnionMembers) -> None: + from toad.forms.form_view import InlineFormView + + super().__init__() + self.field, self.members = field, members + choice = members.selected(field.value) + self.selector = Select( + [(members.label(member), member) for member in members.choices()], + value=Select.NULL if choice is None else choice, + allow_blank=choice is None, id='union-member', + ) + self.draft = members.draft(choice, field.value) + self.form = InlineFormView(self.draft.state) + self.body = containers.VerticalScroll(self.form, id='union-body') + self.feedback = Static('', markup=False) + self.apply_button = Button('Apply', id='union-apply', variant='primary', disabled=True) + + def compose(self) -> ComposeResult: + with containers.Vertical(id='union-box'): + yield Static(self.field.label, markup=False) + yield self.selector + yield self.body + yield self.feedback + with containers.Horizontal(id='union-buttons'): + yield self.apply_button + yield Button('Cancel', id='union-cancel') + + def on_mount(self) -> None: + self.draft.state.on_resolved_changed(self.changed) + self.validate_draft() + + def on_unmount(self) -> None: + self.draft.state.off_resolved_changed(self.changed) + + def changed(self, paths: set[str]) -> None: + self.validate_draft() + + def validate_draft(self) -> None: + try: + self.draft.complete() + except self.draft.refusal as error: + self.feedback.update(str(error)) + self.apply_button.disabled = True + else: + self.feedback.update('') + self.apply_button.disabled = False + + @on(Select.Changed, '#union-member') + async def select_member(self, event: Select.Changed) -> None: + from toad.forms.form_view import InlineFormView + + event.stop() + if event.value is Select.NULL or event.value is self.draft.declaration: + return + self.apply_button.disabled = True + self.draft.state.off_resolved_changed(self.changed) + await self.form.remove() + if not self.is_attached: + return + self.draft = self.members.draft(event.value, self.field.value) + self.form = InlineFormView(self.draft.state) + await self.body.mount(self.form) + self.draft.state.on_resolved_changed(self.changed) + self.validate_draft() + + @on(Button.Pressed, '#union-apply') + def apply_member(self, event: Button.Pressed) -> None: + event.stop() + self.form.finish() + try: + value = self.draft.complete() + except self.draft.refusal as error: + self.feedback.update(str(error)) + self.apply_button.disabled = True + return + self.field.edit(value) + self.dismiss() + + @on(Button.Pressed, '#union-cancel') + def cancel_member(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss() diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index d56175550..c9d9869f1 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -17,7 +17,7 @@ from rich.style import Style from ui_theme.scheme import BackgroundColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour -from toad.forms.declared_views import DeclaredField, DeclaredViews +from toad.forms.declared_views import DeclaredField, DeclaredViews, UnionMembers, declarations from toad.forms.editors import ValueEditor from toad.forms.field_path import FieldPath from toad.widget_view import AUTO, FocusWithinView, WidgetView @@ -75,7 +75,9 @@ def of(state: ObjectState, parent: str = "", for path, parameter in ParameterFormService().parameters_for_state(state, parent): if included is None or path in included: field = DeclaredField(state, path) - views.append(FieldView.showing(type(parameter).__mro__)(field, included)) + views.append(FieldView.showing(( + *declarations(field.annotation), *type(parameter).__mro__, + ))(field, included)) return views def values(self) -> Iterator[ValueView]: @@ -90,7 +92,7 @@ def filter(self, term: str) -> bool: class ValueView(FieldView): """A field with one value: its label, its help and the editor for its kind.""" - shows = (ParameterInfo,) + shows = (ParameterInfo, UnionMembers) CHANGED_MARK = " ●" diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index d7d9232cf..0aa4ff7f6 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -8,15 +8,16 @@ from agent_comms.cli_command import TargetAction from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption -from agent_comms.field_codec import MalformedRecord +from agent_comms.field_codec import EditorMember, MalformedRecord from agent_comms.login import run_login from objectstate import ObjectState -from python_introspect import AnnotationChoices, FieldLabel +from python_introspect import AnnotationChoices, FieldLabel, optional_member_type from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import Button, Checkbox, Static -from toad.forms.editors import ValueEditor +from toad.forms.declared_views import DeclaredField, MemberDraft, UnchosenMemberDraft, UnionMembers +from toad.forms.editors import UnionMemberDialog, ValueEditor from toad.forms.form_view import InlineFormView from toad.forms.kinds import Multiline from toad.screens.modal import ModalScreen, DismissibleScreen @@ -42,6 +43,79 @@ def label(self, choice): )(field) +class CommandMemberDialog(UnionMemberDialog, DismissibleScreen[None]): + """The generic transaction uses Toad's existing mode and dismissal owners.""" + + +@dataclass(frozen=True) +class PreparedUnion(UnionMembers): + """Decoder references are annotation metadata, never ObjectState values.""" + + parameter: TargetField + + def choices(self) -> tuple[object, ...]: + return tuple(member.declaration for member in self.parameter.editor_members) + + def label(self, choice: object) -> str: + return next(member.label for member in self.parameter.editor_members + if member.declaration is choice) + + def selected(self, value: object) -> object: + return self.parameter.editor_selection(value) + + def dialog(self, field: DeclaredField) -> CommandMemberDialog: + return CommandMemberDialog(field, self) + + def draft(self, choice: object, value: object) -> MemberDraft: + if choice is None: + return UnchosenMemberDraft() + member = next(member for member in self.parameter.editor_members + if member.declaration is choice) + return PreparedMemberDraft(member, value, self.selected(value) is choice) + + +class DeclaredDraft: + """One field-derived native input state, shared by command and member forms.""" + + def prepare_draft(self, parameters: tuple[TargetField, ...]) -> None: + self.parameters = parameters + self.choices: dict[str, PreparedChoices] = {} + declarations = [] + for parameter in parameters: + hints: list[object] = [FieldLabel(parameter.description)] + hints.extend(parameter.editor_hints( + lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) + if parameter.editor_members and optional_member_type(parameter.editor_annotation) is None: + hints.append(PreparedUnion(parameter)) + if parameter.multiline: + hints.append(Multiline()) + declarations.append((parameter.name, Annotated[parameter.editor_annotation, *hints], + field(default_factory=lambda initial=parameter.editor_initial: initial))) + self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) + + def arguments(self) -> dict[str, object]: + return {key: value for parameter in self.parameters + for key, value in parameter.argument(self.state.get_resolved_value(parameter.name)).items()} + + +class PreparedMemberDraft(DeclaredDraft, MemberDraft): + refusal = MalformedRecord + + def __init__(self, member: EditorMember, value: object, supplied: bool) -> None: + self.member = member + self.prepare_draft(tuple( + TargetField.of(entry, member.current(entry, value)) if supplied else TargetField.omitted(entry) + for entry in member.fields + )) + + @property + def declaration(self) -> object: + return self.member.declaration + + def complete(self) -> object: + return self.member.complete(self.arguments()) + + class CatalogField(ValueEditor, Button): """The shared model selector edits the form's canonical draft value.""" shows = (ModelConfigOption,) @@ -144,7 +218,7 @@ async def apply(self, context: CommandDialog) -> None: context.dismiss(arguments) -class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyCommandAction,)): +class CommandDialog(DeclaredDraft, DismissibleScreen[dict[str, object]], actions=(ApplyCommandAction,)): DEFAULT_CSS = """ CommandDialog { align: center middle; background: $background 40%; } CommandDialog #command-box { width: 68; max-width: 95%; height: 90%; @@ -160,17 +234,7 @@ class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyComm def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetField, ...]): super().__init__() self.definition, self.target = definition, target - self.choices: dict[str, PreparedChoices] = {} - declarations = [] - for parameter in fields: - hints = [FieldLabel(parameter.description)] - hints.extend(parameter.editor_hints( - lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) - if parameter.multiline: - hints.append(Multiline()) - declarations.append((parameter.name, Annotated[parameter.editor_annotation, *hints], - field(default=parameter.editor_default))) - self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) + self.prepare_draft(fields) self.form = InlineFormView(self.state) self.form.id = 'command-fields' @@ -204,10 +268,6 @@ async def apply_clicked(self, event: Button.Pressed): await ApplyCommandAction().apply(self) - def arguments(self): - return {key: value for parameter in self.definition.editable_fields - for key, value in parameter.argument(self.state.get_resolved_value(parameter.name)).items()} - async def refresh_catalogs(self, arguments): self.query_one('#command-apply', Button).disabled = True try: From 3420a16fb348b76fc4c90cf1369415f33dfbcc5d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:40:05 -0400 Subject: [PATCH 06/22] Derive unchosen labels and drafts from the selection member --- src/toad/forms/declared_views.py | 5 +++-- src/toad/forms/editors.py | 8 +++---- src/toad/widgets/comms_command_dialog.py | 27 ++++++++++++++---------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 920efc90f..3b21dde15 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -46,12 +46,13 @@ class MemberNotChosen(ValueError): class UnchosenMemberDraft(MemberDraft): refusal = MemberNotChosen - def __init__(self) -> None: + def __init__(self, declaration: object) -> None: + self._declaration = declaration self.state = ObjectState(make_dataclass('UnchosenMember', [])()) @property def declaration(self) -> object: - return None + return self._declaration def complete(self) -> object: raise MemberNotChosen('Choose a member') diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index a59c2dec4..3f78ccacc 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -365,8 +365,7 @@ def bind_control(self) -> None: self.paint() def value_text(self, value: object) -> str: - selected = self.members.selected(value) - return 'Choose a member…' if selected is None else self.members.label(selected) + return self.members.label(self.members.selected(value)) def paint(self) -> None: self.label = self.value_text(self.field.value) @@ -409,8 +408,7 @@ def __init__(self, field: DeclaredField, members: UnionMembers) -> None: choice = members.selected(field.value) self.selector = Select( [(members.label(member), member) for member in members.choices()], - value=Select.NULL if choice is None else choice, - allow_blank=choice is None, id='union-member', + value=choice, allow_blank=False, id='union-member', ) self.draft = members.draft(choice, field.value) self.form = InlineFormView(self.draft.state) @@ -453,7 +451,7 @@ async def select_member(self, event: Select.Changed) -> None: from toad.forms.form_view import InlineFormView event.stop() - if event.value is Select.NULL or event.value is self.draft.declaration: + if event.value is self.draft.declaration: return self.apply_button.disabled = True self.draft.state.off_resolved_changed(self.changed) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 0aa4ff7f6..ea5895cf9 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -8,7 +8,7 @@ from agent_comms.cli_command import TargetAction from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption -from agent_comms.field_codec import EditorMember, MalformedRecord +from agent_comms.field_codec import EditorMember, MalformedRecord, MemberDraftFactory, MemberSelection, UnchosenMember from agent_comms.login import run_login from objectstate import ObjectState from python_introspect import AnnotationChoices, FieldLabel, optional_member_type @@ -48,30 +48,35 @@ class CommandMemberDialog(UnionMemberDialog, DismissibleScreen[None]): @dataclass(frozen=True) -class PreparedUnion(UnionMembers): +class PreparedUnion(UnionMembers, MemberDraftFactory[MemberDraft]): """Decoder references are annotation metadata, never ObjectState values.""" parameter: TargetField def choices(self) -> tuple[object, ...]: - return tuple(member.declaration for member in self.parameter.editor_members) + return tuple(member.declaration for member in self.parameter.editor_selections) - def label(self, choice: object) -> str: - return next(member.label for member in self.parameter.editor_members + def selection(self, choice: object) -> MemberSelection: + return next(member for member in self.parameter.editor_selections if member.declaration is choice) + def label(self, choice: object) -> str: + return self.selection(choice).label + def selected(self, value: object) -> object: - return self.parameter.editor_selection(value) + return self.parameter.editor_selection(value).declaration def dialog(self, field: DeclaredField) -> CommandMemberDialog: return CommandMemberDialog(field, self) def draft(self, choice: object, value: object) -> MemberDraft: - if choice is None: - return UnchosenMemberDraft() - member = next(member for member in self.parameter.editor_members - if member.declaration is choice) - return PreparedMemberDraft(member, value, self.selected(value) is choice) + return self.selection(choice).draft(self, value) + + def unchosen(self, member: UnchosenMember) -> MemberDraft: + return UnchosenMemberDraft(member.declaration) + + def member(self, member: EditorMember, value: object) -> MemberDraft: + return PreparedMemberDraft(member, value, self.selected(value) is member.declaration) class DeclaredDraft: From cb3dd7ceda8eb0b3393fd48f67ab7e06f670f0f4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:04:40 -0400 Subject: [PATCH 07/22] Keep the actual member selection on its draft --- src/toad/forms/declared_views.py | 18 +++++++----------- src/toad/forms/editors.py | 2 +- src/toad/widgets/comms_command_dialog.py | 11 ++++------- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 3b21dde15..3acc0cc0f 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, make_dataclass from inspect import cleandoc -from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin +from typing import TYPE_CHECKING, Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin from formbridge.forms.ui_utils import FieldDisplayText from objectstate import DottedFieldPath, ObjectState @@ -22,6 +22,9 @@ from toad.forms.kinds import Placeholder +if TYPE_CHECKING: + from agent_comms.field_codec import MemberSelection + Metadata = TypeVar("Metadata") @@ -29,12 +32,9 @@ class MemberDraft(ABC): """Uncommitted member inputs, never a second committed union value.""" state: ObjectState + selection: MemberSelection refusal: ClassVar[type[Exception]] - @property - @abstractmethod - def declaration(self) -> object: ... - @abstractmethod def complete(self) -> object: ... @@ -46,14 +46,10 @@ class MemberNotChosen(ValueError): class UnchosenMemberDraft(MemberDraft): refusal = MemberNotChosen - def __init__(self, declaration: object) -> None: - self._declaration = declaration + def __init__(self, selection: MemberSelection) -> None: + self.selection = selection self.state = ObjectState(make_dataclass('UnchosenMember', [])()) - @property - def declaration(self) -> object: - return self._declaration - def complete(self) -> object: raise MemberNotChosen('Choose a member') diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 3f78ccacc..5b5d62fcb 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -451,7 +451,7 @@ async def select_member(self, event: Select.Changed) -> None: from toad.forms.form_view import InlineFormView event.stop() - if event.value is self.draft.declaration: + if event.value is self.draft.selection.declaration: return self.apply_button.disabled = True self.draft.state.off_resolved_changed(self.changed) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index ea5895cf9..c15e0bfee 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -73,7 +73,7 @@ def draft(self, choice: object, value: object) -> MemberDraft: return self.selection(choice).draft(self, value) def unchosen(self, member: UnchosenMember) -> MemberDraft: - return UnchosenMemberDraft(member.declaration) + return UnchosenMemberDraft(member) def member(self, member: EditorMember, value: object) -> MemberDraft: return PreparedMemberDraft(member, value, self.selected(value) is member.declaration) @@ -105,20 +105,17 @@ def arguments(self) -> dict[str, object]: class PreparedMemberDraft(DeclaredDraft, MemberDraft): refusal = MalformedRecord + selection: EditorMember def __init__(self, member: EditorMember, value: object, supplied: bool) -> None: - self.member = member + self.selection = member self.prepare_draft(tuple( TargetField.of(entry, member.current(entry, value)) if supplied else TargetField.omitted(entry) for entry in member.fields )) - @property - def declaration(self) -> object: - return self.member.declaration - def complete(self) -> object: - return self.member.complete(self.arguments()) + return self.selection.complete(self.arguments()) class CatalogField(ValueEditor, Button): From 34ff9008199b035a67302cd00d9a6a963dac3971 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:04:13 -0400 Subject: [PATCH 08/22] Keep native member selections through the union control --- src/toad/forms/declared_views.py | 9 +++------ src/toad/forms/editors.py | 16 +++++++++++----- src/toad/widgets/comms_command_dialog.py | 21 +++++++-------------- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 3acc0cc0f..e116fab01 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -58,16 +58,13 @@ class UnionMembers(ABC): """Read-only declared choices; concrete integrations own input construction.""" @abstractmethod - def choices(self) -> tuple[object, ...]: ... + def choices(self) -> tuple[MemberSelection, ...]: ... @abstractmethod - def label(self, choice: object) -> str: ... + def selected(self, value: object) -> MemberSelection: ... @abstractmethod - def selected(self, value: object) -> object: ... - - @abstractmethod - def draft(self, choice: object, value: object) -> MemberDraft: ... + def draft(self, choice: MemberSelection, value: object) -> MemberDraft: ... @abstractmethod def dialog(self, field: DeclaredField) -> ModalScreen[None]: ... diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 5b5d62fcb..ce76ab6b2 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -26,6 +26,7 @@ from textual.screen import ModalScreen from textual.widgets import Button, Checkbox, Input, Select, Static, TextArea from textual.widgets._input import InputType +from textual.widgets.select import NoSelection from toad.forms.declared_views import DeclaredField, DeclaredViews, UnionMembers, declarations from toad.forms.kinds import Multiline @@ -34,6 +35,8 @@ from toad.tone_paint import UntintedTonePaint if TYPE_CHECKING: + from agent_comms.field_codec import MemberSelection + EditorWidget = Widget else: EditorWidget = object @@ -365,7 +368,7 @@ def bind_control(self) -> None: self.paint() def value_text(self, value: object) -> str: - return self.members.label(self.members.selected(value)) + return self.members.selected(value).label def paint(self) -> None: self.label = self.value_text(self.field.value) @@ -406,8 +409,8 @@ def __init__(self, field: DeclaredField, members: UnionMembers) -> None: super().__init__() self.field, self.members = field, members choice = members.selected(field.value) - self.selector = Select( - [(members.label(member), member) for member in members.choices()], + self.selector: Select[MemberSelection] = Select( + [(member.label, member) for member in members.choices()], value=choice, allow_blank=False, id='union-member', ) self.draft = members.draft(choice, field.value) @@ -451,14 +454,17 @@ async def select_member(self, event: Select.Changed) -> None: from toad.forms.form_view import InlineFormView event.stop() - if event.value is self.draft.selection.declaration: + choice = self.selector.value + if isinstance(choice, NoSelection): + raise ValueError('A union selector requires a declared selection') + if choice == self.draft.selection: return self.apply_button.disabled = True self.draft.state.off_resolved_changed(self.changed) await self.form.remove() if not self.is_attached: return - self.draft = self.members.draft(event.value, self.field.value) + self.draft = self.members.draft(choice, self.field.value) self.form = InlineFormView(self.draft.state) await self.body.mount(self.form) self.draft.state.on_resolved_changed(self.changed) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index c15e0bfee..7a3fcde59 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -53,30 +53,23 @@ class PreparedUnion(UnionMembers, MemberDraftFactory[MemberDraft]): parameter: TargetField - def choices(self) -> tuple[object, ...]: - return tuple(member.declaration for member in self.parameter.editor_selections) + def choices(self) -> tuple[MemberSelection, ...]: + return self.parameter.editor_selections - def selection(self, choice: object) -> MemberSelection: - return next(member for member in self.parameter.editor_selections - if member.declaration is choice) - - def label(self, choice: object) -> str: - return self.selection(choice).label - - def selected(self, value: object) -> object: - return self.parameter.editor_selection(value).declaration + def selected(self, value: object) -> MemberSelection: + return self.parameter.editor_selection(value) def dialog(self, field: DeclaredField) -> CommandMemberDialog: return CommandMemberDialog(field, self) - def draft(self, choice: object, value: object) -> MemberDraft: - return self.selection(choice).draft(self, value) + def draft(self, choice: MemberSelection, value: object) -> MemberDraft: + return choice.draft(self, value) def unchosen(self, member: UnchosenMember) -> MemberDraft: return UnchosenMemberDraft(member) def member(self, member: EditorMember, value: object) -> MemberDraft: - return PreparedMemberDraft(member, value, self.selected(value) is member.declaration) + return PreparedMemberDraft(member, value, self.selected(value).declaration is member.declaration) class DeclaredDraft: From b6e71088bf0c364b456362069cb2a8c8e4d3c846 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:36:54 -0400 Subject: [PATCH 09/22] Observe member subtree edits through canonical field paths --- pyproject.toml | 2 +- src/toad/forms/form_view.py | 14 +- tests/textual/test_command_native_members.py | 163 +++++++++++++++++++ 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 tests/textual/test_command_native_members.py diff --git a/pyproject.toml b/pyproject.toml index 0c7948e4b..26e6e236c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } -objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } +objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "1b67e456c493217adaa9bc1ed6e4f2841663c39c" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "95db444b536cba27081dd2c1496f71e25670a446" } diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index c9d9869f1..9c77ff735 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -218,14 +218,20 @@ def leave_state(self, event: events.Unmount) -> None: self.state.off_resolved_changed(self.values_changed) self.state.off_state_changed(self.marks_changed) + def observing_values(self, paths: set[str]) -> Iterator[ValueView]: + """Observe a changed path through its existing ancestor/descendant relation.""" + for view in self.values.values(): + if any(view.field.path.intersects_path(path) for path in paths): + yield view + def values_changed(self, paths: set[str]) -> None: - for path in paths & self.values.keys(): - self.values[path].editor.paint() + for view in self.observing_values(paths): + view.editor.paint() def marks_changed(self, paths: set[str]) -> None: differ = self.state.dirty_fields - for path in paths & self.values.keys(): - self.values[path].mark(path in differ) + for view in self.observing_values(paths): + view.mark(any(view.field.path.intersects_path(path) for path in differ)) def finish(self) -> None: """Send every edit the user has typed but not left yet.""" diff --git a/tests/textual/test_command_native_members.py b/tests/textual/test_command_native_members.py new file mode 100644 index 000000000..0ca23719d --- /dev/null +++ b/tests/textual/test_command_native_members.py @@ -0,0 +1,163 @@ +"""Actual Toad command/member dialogs; no command execution or invented state.""" + +import pytest +from textual.widgets import Button + +from agent_comms.cli_command import TargetAction +from agent_comms.cli_verbs.fork import ForkCliCommand +from agent_comms.cli_verbs.new import NewThreadCliCommand +from agent_comms.field_codec import MalformedRecord +from agent_comms.repo import Checkout +from agent_comms.thread_management import ChangedTags, GivenTags, InheritedTags +from support.ui import TextualUi +from support.workspace import Workspace +from toad.forms.editors import LineEditor, OptionalEditor, UnionEditor, ValueEditor +from toad.widgets.comms_command_dialog import CommandDialog, CommandMemberDialog + + +async def show_command(ui: TextualUi, definition: TargetAction) -> CommandDialog: + dialog = CommandDialog(definition, 'private project', await definition.prepare_fields()) + ui.app.push_screen(dialog) + await ui.until(lambda: dialog.is_attached and all( + view.editor.is_mounted for view in dialog.form.values.values() + ), why='the real command fields to mount') + return dialog + + +async def supply(ui: TextualUi, editor: ValueEditor, text: str) -> None: + control = editor + if isinstance(editor, OptionalEditor): + editor.holds_value.value = True + await ui.step() + control = editor.member + assert isinstance(control, LineEditor) + control.value = text + control.finish(explicit=True) + await ui.step() + + +async def open_member(ui: TextualUi, command: CommandDialog) -> CommandMemberDialog: + editor = command.form.values['tags'].editor + assert isinstance(editor, UnionEditor) + editor.scroll_visible(animate=False, immediate=True) + await ui.step() + assert await ui.pilot.click(editor) + await ui.until(lambda: isinstance(ui.app.screen, CommandMemberDialog), + why='the actual member transaction') + member = ui.app.screen + assert isinstance(member, CommandMemberDialog) + return member + + +@pytest.mark.parametrize('text,expected', [('', frozenset()), ('chosen', frozenset({'chosen'}))]) +async def test_new_dialog_keeps_missing_name_and_tags_distinct_from_explicit_empty( + workspace: Workspace, text: str, expected: frozenset[str] +) -> None: + binding, = NewThreadCliCommand.project_bindings(workspace.comms, Checkout(workspace.project)) + definition = TargetAction((binding,), (), NewThreadCliCommand) + async with workspace.app(session=False) as ui: + dialog = await show_command(ui, definition) + assert 'name' not in dialog.arguments() + assert 'tags' not in dialog.arguments() + assert dialog.query_one('#command-apply', Button).disabled + with pytest.raises(MalformedRecord): + definition.edited(dialog.arguments()) + await supply(ui, dialog.form.values['name'].editor, 'new-child') + assert 'tags' not in dialog.arguments() + with pytest.raises(MalformedRecord): + definition.edited(dialog.arguments()) + await supply(ui, dialog.form.values['tags'].editor, text) + completed, = definition.edited(dialog.arguments()).bound + assert completed.name == 'new-child' + assert completed.tags == GivenTags(expected) + await ui.until(lambda: not dialog.query_one('#command-apply', Button).disabled, + why='explicit name and membership to enable Apply') + # Completing a form must not create or start the command's thread. + assert 'new-child' not in workspace.comms.registry + dialog.dismiss() + + +async def test_fork_member_selection_apply_reopen_cancel_and_native_parent_refresh( + workspace: Workspace, +) -> None: + parent = workspace.thread('original-parent', 'old') + binding = ForkCliCommand.thread_binding(parent) + definition = TargetAction((binding,), (parent.name,), ForkCliCommand) + async with workspace.app(session=False) as ui: + command = await show_command(ui, definition) + assert 'parent' not in command.form.values + assert 'name' not in command.arguments() + await supply(ui, command.form.values['name'].editor, 'fork-child') + untouched, = definition.edited(command.arguments()).bound + assert untouched.tags == InheritedTags() + assert untouched.tags.for_child(frozenset({'later'})) == frozenset({'later'}) + + member = await open_member(ui, command) + given = next(choice for choice in member.members.choices() if choice.declaration is GivenTags) + member.selector.value = given + await ui.until(lambda: member.draft.selection is given and 'value' in member.form.values, + why='GivenTags text editor') + assert member.selector.value is given + await supply(ui, member.form.values['value'].editor, 'old') + assert await ui.pilot.click(member.apply_button) + await ui.until(lambda: ui.app.screen is command, why='one GivenTags commit') + assert command.state.get_resolved_value('tags') == GivenTags(frozenset({'old'})) + + member = await open_member(ui, command) + retired = member.draft.state + changed = next(choice for choice in member.members.choices() if choice.declaration is ChangedTags) + member.selector.value = changed + await ui.until(lambda: member.draft.selection is changed and set(member.form.values) == {'add', 'remove'}, + why='ChangedTags required record fields') + assert member.apply_button.disabled + assert member.draft.arguments() == {} + retired.update_parameter('value', 'retired-input') + await ui.step() + assert member.draft.arguments() == {} + assert member.apply_button.disabled + await supply(ui, member.form.values['add'].editor, '["new"]') + assert member.apply_button.disabled + await supply(ui, member.form.values['remove'].editor, '["old"]') + expected = ChangedTags(frozenset({'new'}), frozenset({'old'})) + assert member.draft.complete() == expected + assert await ui.pilot.click(member.apply_button) + await ui.until(lambda: ui.app.screen is command, why='one ChangedTags commit') + assert command.state.get_resolved_value('tags') == expected + assert set(command.state.direct_parameter_paths('tags')) == { + command.state.parameter_structure.path('tags.add'), + command.state.parameter_structure.path('tags.remove'), + } + assert 'tags.tags' not in command.state.parameters + await ui.step() + assert str(command.form.values['tags'].editor.label) == changed.label + assert command.form.values['tags'] in tuple(command.form.observing_values({'tags.add'})) + completed, = definition.edited(command.arguments()).bound + assert completed.tags == expected + assert completed.parent == parent.name + + member = await open_member(ui, command) + assert member.draft.selection.declaration is ChangedTags + assert member.draft.complete() == expected + await supply(ui, member.form.values['add'].editor, '["cancelled"]') + assert await ui.pilot.click(member.query_one('#union-cancel', Button)) + await ui.until(lambda: ui.app.screen is command, why='member transaction cancelled') + assert command.state.get_resolved_value('tags') == expected + assert 'fork-child' not in workspace.comms.registry + with pytest.raises(ValueError, match='Target-bound'): + await command.refresh_catalogs({'parent': 'not-the-captured-parent'}) + with pytest.raises(ValueError, match='not offered'): + await command.refresh_catalogs({'unoffered': 'not-a-command-field'}) + command.dismiss() + + +async def test_actual_settings_observes_a_changed_ancestor(workspace: Workspace) -> None: + from toad.screens.settings import SettingsScreen + + async with workspace.app(session=False) as ui: + settings = SettingsScreen() + ui.app.push_screen(settings) + await ui.until(lambda: settings.is_attached, why='the actual settings form') + width = settings.form.values['ui.column_width'] + assert width in tuple(settings.form.observing_values({'ui'})) + assert width in tuple(settings.form.observing_values({'ui.column_width'})) + settings.dismiss() From d0516585554ab926af4c698eea794a17fdcb5f8f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:42:13 -0400 Subject: [PATCH 10/22] Keep member selection native and use actual editor commit controls --- src/toad/widgets/comms_command_dialog.py | 2 +- tests/textual/test_command_native_members.py | 41 +++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 7a3fcde59..f9602b8ed 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -69,7 +69,7 @@ def unchosen(self, member: UnchosenMember) -> MemberDraft: return UnchosenMemberDraft(member) def member(self, member: EditorMember, value: object) -> MemberDraft: - return PreparedMemberDraft(member, value, self.selected(value).declaration is member.declaration) + return PreparedMemberDraft(member, value, self.selected(value) == member) class DeclaredDraft: diff --git a/tests/textual/test_command_native_members.py b/tests/textual/test_command_native_members.py index 0ca23719d..ca88487d9 100644 --- a/tests/textual/test_command_native_members.py +++ b/tests/textual/test_command_native_members.py @@ -25,14 +25,9 @@ async def show_command(ui: TextualUi, definition: TargetAction) -> CommandDialog async def supply(ui: TextualUi, editor: ValueEditor, text: str) -> None: - control = editor - if isinstance(editor, OptionalEditor): - editor.holds_value.value = True - await ui.step() - control = editor.member - assert isinstance(control, LineEditor) - control.value = text - control.finish(explicit=True) + assert isinstance(editor, LineEditor) + editor.value = text + editor.commit() await ui.step() @@ -62,11 +57,19 @@ async def test_new_dialog_keeps_missing_name_and_tags_distinct_from_explicit_emp assert dialog.query_one('#command-apply', Button).disabled with pytest.raises(MalformedRecord): definition.edited(dialog.arguments()) - await supply(ui, dialog.form.values['name'].editor, 'new-child') + name = dialog.form.values['name'].editor + assert isinstance(name, OptionalEditor) + name.holds_value.value = True + await ui.step() + await supply(ui, name.member, 'new-child') assert 'tags' not in dialog.arguments() with pytest.raises(MalformedRecord): definition.edited(dialog.arguments()) - await supply(ui, dialog.form.values['tags'].editor, text) + tags = dialog.form.values['tags'].editor + assert isinstance(tags, OptionalEditor) + tags.holds_value.value = True + await ui.step() + await supply(ui, tags.member, text) completed, = definition.edited(dialog.arguments()).bound assert completed.name == 'new-child' assert completed.tags == GivenTags(expected) @@ -87,7 +90,11 @@ async def test_fork_member_selection_apply_reopen_cancel_and_native_parent_refre command = await show_command(ui, definition) assert 'parent' not in command.form.values assert 'name' not in command.arguments() - await supply(ui, command.form.values['name'].editor, 'fork-child') + name = command.form.values['name'].editor + assert isinstance(name, OptionalEditor) + name.holds_value.value = True + await ui.step() + await supply(ui, name.member, 'fork-child') untouched, = definition.edited(command.arguments()).bound assert untouched.tags == InheritedTags() assert untouched.tags.for_child(frozenset({'later'})) == frozenset({'later'}) @@ -115,9 +122,17 @@ async def test_fork_member_selection_apply_reopen_cancel_and_native_parent_refre await ui.step() assert member.draft.arguments() == {} assert member.apply_button.disabled - await supply(ui, member.form.values['add'].editor, '["new"]') + add = member.form.values['add'].editor + remove = member.form.values['remove'].editor + assert isinstance(add, OptionalEditor) + assert isinstance(remove, OptionalEditor) + add.holds_value.value = True + await ui.step() + await supply(ui, add.member, '["new"]') assert member.apply_button.disabled - await supply(ui, member.form.values['remove'].editor, '["old"]') + remove.holds_value.value = True + await ui.step() + await supply(ui, remove.member, '["old"]') expected = ChangedTags(frozenset({'new'}), frozenset({'old'})) assert member.draft.complete() == expected assert await ui.pilot.click(member.apply_button) From 3410ec463fc0080743e090e1ed3718f7d97a5dac Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:45:46 -0400 Subject: [PATCH 11/22] Lock the published native topology provider --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 26e6e236c..2c3690269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } -objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "1b67e456c493217adaa9bc1ed6e4f2841663c39c" } +objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "1f859f06f70accd8c524a69b196d2837b0d79c70" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "95db444b536cba27081dd2c1496f71e25670a446" } diff --git a/uv.lock b/uv.lock index eb16acae4..24eaa449a 100644 --- a/uv.lock +++ b/uv.lock @@ -282,7 +282,7 @@ requires-dist = [ { name = "metaclass-registry", git = "https://github.com/OpenHCSDev/metaclass-registry?branch=main" }, { name = "msgpack", specifier = "==1.2.2" }, { name = "notify-py", specifier = "==0.3.43" }, - { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400" }, + { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=1f859f06f70accd8c524a69b196d2837b0d79c70" }, { name = "packaging", specifier = "==26.2" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "platformdirs", specifier = "==4.9.6" }, @@ -873,7 +873,7 @@ wheels = [ [[package]] name = "objectstate" version = "5.0.0" -source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400#b5dd9f78b9293df145d7629f21deb89b042b9400" } +source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=1f859f06f70accd8c524a69b196d2837b0d79c70#1f859f06f70accd8c524a69b196d2837b0d79c70" } dependencies = [ { name = "dill" }, { name = "pycodify" }, From 5cc8e6186f427669ee648cff4be78dd4c4c84f09 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:15:55 -0400 Subject: [PATCH 12/22] Keep missing command inputs unset until the creator supplies them --- src/toad/forms/editors.py | 29 ++++++++++++++++++------ src/toad/widgets/comms_command_dialog.py | 29 +++++++++++++++++------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index a40dc41b0..625e183cc 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -79,8 +79,8 @@ def paint(self) -> None: """Paint the control from the state.""" raise NotImplementedError - def finish(self) -> None: - """Send an edit the user has not finished yet. Most controls have none.""" + def finish(self, *, explicit: bool = False) -> None: + """Commit pending input; explicit also confirms an unchanged displayed value.""" def value_text(self, value: object) -> str: """How a value of this field reads.""" @@ -115,6 +115,10 @@ def changed(self, message: Checkbox.Changed) -> None: message.stop() self.send(message.value) + def finish(self, *, explicit: bool = False) -> None: + if explicit: + self.send(self.value) + def paint(self) -> None: with self.prevent(Checkbox.Changed): self.value = bool(self.field.value) @@ -152,8 +156,8 @@ def finished(self, message: Input.Blurred | Input.Submitted) -> None: message.stop() self.finish() - def finish(self) -> None: - if self.value == self.value_text(self.field.value): + def finish(self, *, explicit: bool = False) -> None: + if not explicit and self.value == self.value_text(self.field.value): return try: value = self.read(self.value) @@ -198,6 +202,10 @@ def changed(self, message: TextArea.Changed) -> None: message.stop() self.send(self.text) + def finish(self, *, explicit: bool = False) -> None: + if explicit: + self.send(self.text) + def paint(self) -> None: value = str(self.field.value or "") if self.text != value: @@ -247,6 +255,10 @@ def changed(self, message: Select.Changed) -> None: if message.value is not Select.NULL: self.send(message.value) + def finish(self, *, explicit: bool = False) -> None: + if explicit and self.value is not Select.NULL: + self.send(self.value) + def paint(self) -> None: value = self.field.value with self.prevent(Select.Changed): @@ -304,12 +316,15 @@ def member_feedback(self, message: ValueEditor.Feedback) -> None: def held_changed(self, message: Checkbox.Changed) -> None: message.stop() self.member.display = message.value - if not message.value: + if message.value: + self.member.finish(explicit=True) + else: self.member.set_reactive(ValueEditor.feedback, "") self.send(None) - def finish(self) -> None: - self.member.finish() + def finish(self, *, explicit: bool = False) -> None: + if self.holds_value.value: + self.member.finish(explicit=explicit) def value_text(self, value: object) -> str: return "not set" if value is None else self.member.value_text(value) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 8f72beca7..f046dcd52 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -6,6 +6,7 @@ from typing import Annotated from agent_comms.cli_command import TargetAction +from agent_comms.cli_failures import CLI_FAILURES from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption from agent_comms.field_codec import compile_decoder @@ -165,7 +166,10 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) if parameter.multiline: hints.append(Multiline()) - declarations.append((parameter.name, Annotated[compile_decoder(parameter.annotation).editor_annotation, *hints], + annotation = compile_decoder(parameter.annotation).editor_annotation + if parameter.required and not parameter.supplied: + annotation = annotation | type(None) + declarations.append((parameter.name, Annotated[annotation, *hints], field(default=parameter.editor_default))) self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) self.form = InlineFormView(self.state) @@ -202,13 +206,19 @@ async def apply_clicked(self, event: Button.Pressed): def arguments(self): - return {name: self.state.get_resolved_value(name) for name in self.form.values} + return {parameter.name: value for parameter in self.definition.editable_fields + if (value := self.state.get_resolved_value(parameter.name)) is not None + or not parameter.required} async def refresh_catalogs(self, arguments): self.query_one('#command-apply', Button).disabled = True - edited = self.definition.edited(arguments) - fields = await self.app.preparation.run_thread( - lambda: asyncio.run(edited.prepare_fields())) + try: + fields = await self.app.preparation.run_thread( + lambda: asyncio.run(self.definition.prepare_fields(arguments))) + except CLI_FAILURES as error: + if self.is_attached and arguments == self.arguments(): + self.query_one('#command-confirmation', Static).update(str(error)) + return if not self.is_attached or arguments != self.arguments(): return for parameter in fields: @@ -225,15 +235,18 @@ def update_confirmation(self): async def prepare(): def read(): for name, choices in self.choices.items(): - if arguments[name] not in choices.choices(): + if name not in arguments or arguments[name] not in choices.choices(): return f'Choose {name.replace("_", " ")}', False - return self.definition.edited(arguments).confirmation, True + try: + return self.definition.edited(arguments).confirmation, True + except CLI_FAILURES as error: + return str(error), False confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): return self.query_one('#command-confirmation', Static).update(confirmation) confirmed.display = valid and bool(confirmation) self.query_one('#command-apply', Button).disabled = not valid or any( - arguments[name] != choices.parameter.editor_default + name not in arguments or arguments[name] != choices.parameter.editor_default for name, choices in self.choices.items()) self.run_worker(prepare, group='command-confirmation', exclusive=True) From 5b991f6767bba2a1971b83c263a4748376c27474 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:18:48 -0400 Subject: [PATCH 13/22] Make explicit value commit an editor operation instead of a mode flag --- src/toad/forms/editors.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 625e183cc..4cf99268a 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -79,8 +79,11 @@ def paint(self) -> None: """Paint the control from the state.""" raise NotImplementedError - def finish(self, *, explicit: bool = False) -> None: - """Commit pending input; explicit also confirms an unchanged displayed value.""" + def finish(self) -> None: + """Send an edit the user has not finished yet. Most controls have none.""" + + def commit(self) -> None: + """Explicitly supply the displayed input, even when it is unchanged.""" def value_text(self, value: object) -> str: """How a value of this field reads.""" @@ -115,9 +118,8 @@ def changed(self, message: Checkbox.Changed) -> None: message.stop() self.send(message.value) - def finish(self, *, explicit: bool = False) -> None: - if explicit: - self.send(self.value) + def commit(self) -> None: + self.send(self.value) def paint(self) -> None: with self.prevent(Checkbox.Changed): @@ -156,9 +158,12 @@ def finished(self, message: Input.Blurred | Input.Submitted) -> None: message.stop() self.finish() - def finish(self, *, explicit: bool = False) -> None: - if not explicit and self.value == self.value_text(self.field.value): + def finish(self) -> None: + if self.value == self.value_text(self.field.value): return + self.commit() + + def commit(self) -> None: try: value = self.read(self.value) except ValueError as error: @@ -202,9 +207,8 @@ def changed(self, message: TextArea.Changed) -> None: message.stop() self.send(self.text) - def finish(self, *, explicit: bool = False) -> None: - if explicit: - self.send(self.text) + def commit(self) -> None: + self.send(self.text) def paint(self) -> None: value = str(self.field.value or "") @@ -255,8 +259,8 @@ def changed(self, message: Select.Changed) -> None: if message.value is not Select.NULL: self.send(message.value) - def finish(self, *, explicit: bool = False) -> None: - if explicit and self.value is not Select.NULL: + def commit(self) -> None: + if self.value is not Select.NULL: self.send(self.value) def paint(self) -> None: @@ -317,14 +321,14 @@ def held_changed(self, message: Checkbox.Changed) -> None: message.stop() self.member.display = message.value if message.value: - self.member.finish(explicit=True) + self.member.commit() else: self.member.set_reactive(ValueEditor.feedback, "") self.send(None) - def finish(self, *, explicit: bool = False) -> None: + def finish(self) -> None: if self.holds_value.value: - self.member.finish(explicit=explicit) + self.member.finish() def value_text(self, value: object) -> str: return "not set" if value is None else self.member.value_text(value) From 03397e20fcdea4f0d45b82b8127f00e58aa59995 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:22:22 -0400 Subject: [PATCH 14/22] Consume declared input presence and require editor commits --- src/toad/forms/editors.py | 8 ++++++++ src/toad/widgets/comms_command_dialog.py | 22 ++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 4cf99268a..a13339e41 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Iterable from enum import Enum from typing import TYPE_CHECKING, ClassVar, Optional @@ -82,6 +83,7 @@ def paint(self) -> None: def finish(self) -> None: """Send an edit the user has not finished yet. Most controls have none.""" + @abstractmethod def commit(self) -> None: """Explicitly supply the displayed input, even when it is unchanged.""" @@ -330,6 +332,12 @@ def finish(self) -> None: if self.holds_value.value: self.member.finish() + def commit(self) -> None: + if self.holds_value.value: + self.member.commit() + else: + self.send(None) + def value_text(self, value: object) -> str: return "not set" if value is None else self.member.value_text(value) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index f046dcd52..131d8927f 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -6,10 +6,9 @@ from typing import Annotated from agent_comms.cli_command import TargetAction -from agent_comms.cli_failures import CLI_FAILURES from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption -from agent_comms.field_codec import compile_decoder +from agent_comms.field_codec import MalformedRecord from agent_comms.login import run_login from objectstate import ObjectState from python_introspect import AnnotationChoices, FieldLabel @@ -54,6 +53,9 @@ def paint(self): self.label = next((choice.name for choice in self.field.metadata(PreparedChoices).parameter.options if choice.value == self.field.value), 'Choose / connect a provider…') + def commit(self) -> None: + self.send(self.field.value) + def on_button_pressed(self, event: Button.Pressed): event.stop() self.choose() @@ -158,7 +160,7 @@ class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyComm def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetField, ...]): super().__init__() self.definition, self.target = definition, target - self.choices = {} + self.choices: dict[str, PreparedChoices] = {} declarations = [] for parameter in fields: hints = [FieldLabel(parameter.description)] @@ -166,10 +168,7 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) if parameter.multiline: hints.append(Multiline()) - annotation = compile_decoder(parameter.annotation).editor_annotation - if parameter.required and not parameter.supplied: - annotation = annotation | type(None) - declarations.append((parameter.name, Annotated[annotation, *hints], + declarations.append((parameter.name, Annotated[parameter.editor_annotation, *hints], field(default=parameter.editor_default))) self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) self.form = InlineFormView(self.state) @@ -206,16 +205,15 @@ async def apply_clicked(self, event: Button.Pressed): def arguments(self): - return {parameter.name: value for parameter in self.definition.editable_fields - if (value := self.state.get_resolved_value(parameter.name)) is not None - or not parameter.required} + return {key: value for parameter in self.definition.editable_fields + for key, value in parameter.argument(self.state.get_resolved_value(parameter.name)).items()} async def refresh_catalogs(self, arguments): self.query_one('#command-apply', Button).disabled = True try: fields = await self.app.preparation.run_thread( lambda: asyncio.run(self.definition.prepare_fields(arguments))) - except CLI_FAILURES as error: + except (ValueError, MalformedRecord) as error: if self.is_attached and arguments == self.arguments(): self.query_one('#command-confirmation', Static).update(str(error)) return @@ -239,7 +237,7 @@ def read(): return f'Choose {name.replace("_", " ")}', False try: return self.definition.edited(arguments).confirmation, True - except CLI_FAILURES as error: + except (ValueError, MalformedRecord) as error: return str(error), False confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): From 37b4cc501843729251c0d3f2c322921eaad2dd67 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:45:56 -0400 Subject: [PATCH 15/22] Catch only malformed editor data in command forms --- src/toad/widgets/comms_command_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 131d8927f..d7d9232cf 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -213,7 +213,7 @@ async def refresh_catalogs(self, arguments): try: fields = await self.app.preparation.run_thread( lambda: asyncio.run(self.definition.prepare_fields(arguments))) - except (ValueError, MalformedRecord) as error: + except MalformedRecord as error: if self.is_attached and arguments == self.arguments(): self.query_one('#command-confirmation', Static).update(str(error)) return @@ -237,7 +237,7 @@ def read(): return f'Choose {name.replace("_", " ")}', False try: return self.definition.edited(arguments).confirmation, True - except (ValueError, MalformedRecord) as error: + except MalformedRecord as error: return str(error), False confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): From 6065dda9d6423cc6ed473b3b9b39bba85ccff3b3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:21:12 -0400 Subject: [PATCH 16/22] Edit generic union members as cancellable native transactions --- src/toad/forms/declared_views.py | 55 ++++++++- src/toad/forms/editors.py | 142 ++++++++++++++++++++++- src/toad/forms/form_view.py | 8 +- src/toad/widgets/comms_command_dialog.py | 98 +++++++++++++--- 4 files changed, 277 insertions(+), 26 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 394caf1ec..920efc90f 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -2,8 +2,9 @@ from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Iterator -from dataclasses import dataclass +from dataclasses import dataclass, make_dataclass from inspect import cleandoc from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin @@ -17,12 +18,64 @@ resolve_optional, ) from python_introspect.validation import validate_annotation_value +from textual.screen import ModalScreen from toad.forms.kinds import Placeholder Metadata = TypeVar("Metadata") +class MemberDraft(ABC): + """Uncommitted member inputs, never a second committed union value.""" + + state: ObjectState + refusal: ClassVar[type[Exception]] + + @property + @abstractmethod + def declaration(self) -> object: ... + + @abstractmethod + def complete(self) -> object: ... + + +class MemberNotChosen(ValueError): + """The transaction has not selected an original declaration yet.""" + + +class UnchosenMemberDraft(MemberDraft): + refusal = MemberNotChosen + + def __init__(self) -> None: + self.state = ObjectState(make_dataclass('UnchosenMember', [])()) + + @property + def declaration(self) -> object: + return None + + def complete(self) -> object: + raise MemberNotChosen('Choose a member') + + +class UnionMembers(ABC): + """Read-only declared choices; concrete integrations own input construction.""" + + @abstractmethod + def choices(self) -> tuple[object, ...]: ... + + @abstractmethod + def label(self, choice: object) -> str: ... + + @abstractmethod + def selected(self, value: object) -> object: ... + + @abstractmethod + def draft(self, choice: object, value: object) -> MemberDraft: ... + + @abstractmethod + def dialog(self, field: DeclaredField) -> ModalScreen[None]: ... + + class DeclaredViews: """A family of widget classes, each registered under what it shows. diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index a13339e41..a59c2dec4 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -14,7 +14,7 @@ resolve_annotated, ) from python_introspect.validation import validate_annotation_value -from textual import containers +from textual import containers, on, work from textual.app import ComposeResult from textual.message import Message from textual.reactive import var @@ -23,10 +23,11 @@ from textual.css.scalar import Scalar from textual.color import TRANSPARENT from textual.widget import Widget -from textual.widgets import Checkbox, Input, Select, TextArea +from textual.screen import ModalScreen +from textual.widgets import Button, Checkbox, Input, Select, Static, TextArea from textual.widgets._input import InputType -from toad.forms.declared_views import DeclaredField, DeclaredViews, declarations +from toad.forms.declared_views import DeclaredField, DeclaredViews, UnionMembers, declarations from toad.forms.kinds import Multiline from toad.forms.presentation import FormCheckbox, FormInput, FormSelectCurrent, FramedControl from toad.widget_view import WidgetView @@ -347,3 +348,138 @@ def paint(self) -> None: self.holds_value.value = held self.member.display = held self.member.paint() + + +class UnionEditor(ValueEditor, Button): + """Choose and edit a declared member without changing the parent until Apply.""" + + shows = (UnionMembers,) + + @property + def members(self) -> UnionMembers: + metadata = self.field.metadata(UnionMembers) + assert metadata is not None + return metadata + + def bind_control(self) -> None: + self.paint() + + def value_text(self, value: object) -> str: + selected = self.members.selected(value) + return 'Choose a member…' if selected is None else self.members.label(selected) + + def paint(self) -> None: + self.label = self.value_text(self.field.value) + + def commit(self) -> None: + self.send(self.field.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.choose() + + @work(exclusive=True) + async def choose(self) -> None: + dialog = self.members.dialog(self.field) + try: + await self.app.push_screen_wait(dialog) + finally: + if dialog.is_attached: + dialog.dismiss() + if self.is_attached: + self.paint() + + +class UnionMemberDialog(ModalScreen[None]): + """One pending member transaction; its ObjectState never holds a valid parent.""" + + DEFAULT_CSS = ''' + UnionMemberDialog { align: center middle; background: $background 40%; } + UnionMemberDialog #union-box { width: 68; max-width: 95%; height: 90%; + background: $surface; border: solid $primary; padding: 1 2; } + UnionMemberDialog #union-body { height: 1fr; } + UnionMemberDialog #union-buttons { height: 3; } + ''' + + def __init__(self, field: DeclaredField, members: UnionMembers) -> None: + from toad.forms.form_view import InlineFormView + + super().__init__() + self.field, self.members = field, members + choice = members.selected(field.value) + self.selector = Select( + [(members.label(member), member) for member in members.choices()], + value=Select.NULL if choice is None else choice, + allow_blank=choice is None, id='union-member', + ) + self.draft = members.draft(choice, field.value) + self.form = InlineFormView(self.draft.state) + self.body = containers.VerticalScroll(self.form, id='union-body') + self.feedback = Static('', markup=False) + self.apply_button = Button('Apply', id='union-apply', variant='primary', disabled=True) + + def compose(self) -> ComposeResult: + with containers.Vertical(id='union-box'): + yield Static(self.field.label, markup=False) + yield self.selector + yield self.body + yield self.feedback + with containers.Horizontal(id='union-buttons'): + yield self.apply_button + yield Button('Cancel', id='union-cancel') + + def on_mount(self) -> None: + self.draft.state.on_resolved_changed(self.changed) + self.validate_draft() + + def on_unmount(self) -> None: + self.draft.state.off_resolved_changed(self.changed) + + def changed(self, paths: set[str]) -> None: + self.validate_draft() + + def validate_draft(self) -> None: + try: + self.draft.complete() + except self.draft.refusal as error: + self.feedback.update(str(error)) + self.apply_button.disabled = True + else: + self.feedback.update('') + self.apply_button.disabled = False + + @on(Select.Changed, '#union-member') + async def select_member(self, event: Select.Changed) -> None: + from toad.forms.form_view import InlineFormView + + event.stop() + if event.value is Select.NULL or event.value is self.draft.declaration: + return + self.apply_button.disabled = True + self.draft.state.off_resolved_changed(self.changed) + await self.form.remove() + if not self.is_attached: + return + self.draft = self.members.draft(event.value, self.field.value) + self.form = InlineFormView(self.draft.state) + await self.body.mount(self.form) + self.draft.state.on_resolved_changed(self.changed) + self.validate_draft() + + @on(Button.Pressed, '#union-apply') + def apply_member(self, event: Button.Pressed) -> None: + event.stop() + self.form.finish() + try: + value = self.draft.complete() + except self.draft.refusal as error: + self.feedback.update(str(error)) + self.apply_button.disabled = True + return + self.field.edit(value) + self.dismiss() + + @on(Button.Pressed, '#union-cancel') + def cancel_member(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss() diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index d56175550..c9d9869f1 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -17,7 +17,7 @@ from rich.style import Style from ui_theme.scheme import BackgroundColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour -from toad.forms.declared_views import DeclaredField, DeclaredViews +from toad.forms.declared_views import DeclaredField, DeclaredViews, UnionMembers, declarations from toad.forms.editors import ValueEditor from toad.forms.field_path import FieldPath from toad.widget_view import AUTO, FocusWithinView, WidgetView @@ -75,7 +75,9 @@ def of(state: ObjectState, parent: str = "", for path, parameter in ParameterFormService().parameters_for_state(state, parent): if included is None or path in included: field = DeclaredField(state, path) - views.append(FieldView.showing(type(parameter).__mro__)(field, included)) + views.append(FieldView.showing(( + *declarations(field.annotation), *type(parameter).__mro__, + ))(field, included)) return views def values(self) -> Iterator[ValueView]: @@ -90,7 +92,7 @@ def filter(self, term: str) -> bool: class ValueView(FieldView): """A field with one value: its label, its help and the editor for its kind.""" - shows = (ParameterInfo,) + shows = (ParameterInfo, UnionMembers) CHANGED_MARK = " ●" diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index d7d9232cf..0aa4ff7f6 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -8,15 +8,16 @@ from agent_comms.cli_command import TargetAction from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption -from agent_comms.field_codec import MalformedRecord +from agent_comms.field_codec import EditorMember, MalformedRecord from agent_comms.login import run_login from objectstate import ObjectState -from python_introspect import AnnotationChoices, FieldLabel +from python_introspect import AnnotationChoices, FieldLabel, optional_member_type from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import Button, Checkbox, Static -from toad.forms.editors import ValueEditor +from toad.forms.declared_views import DeclaredField, MemberDraft, UnchosenMemberDraft, UnionMembers +from toad.forms.editors import UnionMemberDialog, ValueEditor from toad.forms.form_view import InlineFormView from toad.forms.kinds import Multiline from toad.screens.modal import ModalScreen, DismissibleScreen @@ -42,6 +43,79 @@ def label(self, choice): )(field) +class CommandMemberDialog(UnionMemberDialog, DismissibleScreen[None]): + """The generic transaction uses Toad's existing mode and dismissal owners.""" + + +@dataclass(frozen=True) +class PreparedUnion(UnionMembers): + """Decoder references are annotation metadata, never ObjectState values.""" + + parameter: TargetField + + def choices(self) -> tuple[object, ...]: + return tuple(member.declaration for member in self.parameter.editor_members) + + def label(self, choice: object) -> str: + return next(member.label for member in self.parameter.editor_members + if member.declaration is choice) + + def selected(self, value: object) -> object: + return self.parameter.editor_selection(value) + + def dialog(self, field: DeclaredField) -> CommandMemberDialog: + return CommandMemberDialog(field, self) + + def draft(self, choice: object, value: object) -> MemberDraft: + if choice is None: + return UnchosenMemberDraft() + member = next(member for member in self.parameter.editor_members + if member.declaration is choice) + return PreparedMemberDraft(member, value, self.selected(value) is choice) + + +class DeclaredDraft: + """One field-derived native input state, shared by command and member forms.""" + + def prepare_draft(self, parameters: tuple[TargetField, ...]) -> None: + self.parameters = parameters + self.choices: dict[str, PreparedChoices] = {} + declarations = [] + for parameter in parameters: + hints: list[object] = [FieldLabel(parameter.description)] + hints.extend(parameter.editor_hints( + lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) + if parameter.editor_members and optional_member_type(parameter.editor_annotation) is None: + hints.append(PreparedUnion(parameter)) + if parameter.multiline: + hints.append(Multiline()) + declarations.append((parameter.name, Annotated[parameter.editor_annotation, *hints], + field(default_factory=lambda initial=parameter.editor_initial: initial))) + self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) + + def arguments(self) -> dict[str, object]: + return {key: value for parameter in self.parameters + for key, value in parameter.argument(self.state.get_resolved_value(parameter.name)).items()} + + +class PreparedMemberDraft(DeclaredDraft, MemberDraft): + refusal = MalformedRecord + + def __init__(self, member: EditorMember, value: object, supplied: bool) -> None: + self.member = member + self.prepare_draft(tuple( + TargetField.of(entry, member.current(entry, value)) if supplied else TargetField.omitted(entry) + for entry in member.fields + )) + + @property + def declaration(self) -> object: + return self.member.declaration + + def complete(self) -> object: + return self.member.complete(self.arguments()) + + class CatalogField(ValueEditor, Button): """The shared model selector edits the form's canonical draft value.""" shows = (ModelConfigOption,) @@ -144,7 +218,7 @@ async def apply(self, context: CommandDialog) -> None: context.dismiss(arguments) -class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyCommandAction,)): +class CommandDialog(DeclaredDraft, DismissibleScreen[dict[str, object]], actions=(ApplyCommandAction,)): DEFAULT_CSS = """ CommandDialog { align: center middle; background: $background 40%; } CommandDialog #command-box { width: 68; max-width: 95%; height: 90%; @@ -160,17 +234,7 @@ class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyComm def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetField, ...]): super().__init__() self.definition, self.target = definition, target - self.choices: dict[str, PreparedChoices] = {} - declarations = [] - for parameter in fields: - hints = [FieldLabel(parameter.description)] - hints.extend(parameter.editor_hints( - lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) - if parameter.multiline: - hints.append(Multiline()) - declarations.append((parameter.name, Annotated[parameter.editor_annotation, *hints], - field(default=parameter.editor_default))) - self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) + self.prepare_draft(fields) self.form = InlineFormView(self.state) self.form.id = 'command-fields' @@ -204,10 +268,6 @@ async def apply_clicked(self, event: Button.Pressed): await ApplyCommandAction().apply(self) - def arguments(self): - return {key: value for parameter in self.definition.editable_fields - for key, value in parameter.argument(self.state.get_resolved_value(parameter.name)).items()} - async def refresh_catalogs(self, arguments): self.query_one('#command-apply', Button).disabled = True try: From fb3fb1622422690c24c35ecc50a67dfed2368f69 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:40:05 -0400 Subject: [PATCH 17/22] Derive unchosen labels and drafts from the selection member --- src/toad/forms/declared_views.py | 5 +++-- src/toad/forms/editors.py | 8 +++---- src/toad/widgets/comms_command_dialog.py | 27 ++++++++++++++---------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 920efc90f..3b21dde15 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -46,12 +46,13 @@ class MemberNotChosen(ValueError): class UnchosenMemberDraft(MemberDraft): refusal = MemberNotChosen - def __init__(self) -> None: + def __init__(self, declaration: object) -> None: + self._declaration = declaration self.state = ObjectState(make_dataclass('UnchosenMember', [])()) @property def declaration(self) -> object: - return None + return self._declaration def complete(self) -> object: raise MemberNotChosen('Choose a member') diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index a59c2dec4..3f78ccacc 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -365,8 +365,7 @@ def bind_control(self) -> None: self.paint() def value_text(self, value: object) -> str: - selected = self.members.selected(value) - return 'Choose a member…' if selected is None else self.members.label(selected) + return self.members.label(self.members.selected(value)) def paint(self) -> None: self.label = self.value_text(self.field.value) @@ -409,8 +408,7 @@ def __init__(self, field: DeclaredField, members: UnionMembers) -> None: choice = members.selected(field.value) self.selector = Select( [(members.label(member), member) for member in members.choices()], - value=Select.NULL if choice is None else choice, - allow_blank=choice is None, id='union-member', + value=choice, allow_blank=False, id='union-member', ) self.draft = members.draft(choice, field.value) self.form = InlineFormView(self.draft.state) @@ -453,7 +451,7 @@ async def select_member(self, event: Select.Changed) -> None: from toad.forms.form_view import InlineFormView event.stop() - if event.value is Select.NULL or event.value is self.draft.declaration: + if event.value is self.draft.declaration: return self.apply_button.disabled = True self.draft.state.off_resolved_changed(self.changed) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 0aa4ff7f6..ea5895cf9 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -8,7 +8,7 @@ from agent_comms.cli_command import TargetAction from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption -from agent_comms.field_codec import EditorMember, MalformedRecord +from agent_comms.field_codec import EditorMember, MalformedRecord, MemberDraftFactory, MemberSelection, UnchosenMember from agent_comms.login import run_login from objectstate import ObjectState from python_introspect import AnnotationChoices, FieldLabel, optional_member_type @@ -48,30 +48,35 @@ class CommandMemberDialog(UnionMemberDialog, DismissibleScreen[None]): @dataclass(frozen=True) -class PreparedUnion(UnionMembers): +class PreparedUnion(UnionMembers, MemberDraftFactory[MemberDraft]): """Decoder references are annotation metadata, never ObjectState values.""" parameter: TargetField def choices(self) -> tuple[object, ...]: - return tuple(member.declaration for member in self.parameter.editor_members) + return tuple(member.declaration for member in self.parameter.editor_selections) - def label(self, choice: object) -> str: - return next(member.label for member in self.parameter.editor_members + def selection(self, choice: object) -> MemberSelection: + return next(member for member in self.parameter.editor_selections if member.declaration is choice) + def label(self, choice: object) -> str: + return self.selection(choice).label + def selected(self, value: object) -> object: - return self.parameter.editor_selection(value) + return self.parameter.editor_selection(value).declaration def dialog(self, field: DeclaredField) -> CommandMemberDialog: return CommandMemberDialog(field, self) def draft(self, choice: object, value: object) -> MemberDraft: - if choice is None: - return UnchosenMemberDraft() - member = next(member for member in self.parameter.editor_members - if member.declaration is choice) - return PreparedMemberDraft(member, value, self.selected(value) is choice) + return self.selection(choice).draft(self, value) + + def unchosen(self, member: UnchosenMember) -> MemberDraft: + return UnchosenMemberDraft(member.declaration) + + def member(self, member: EditorMember, value: object) -> MemberDraft: + return PreparedMemberDraft(member, value, self.selected(value) is member.declaration) class DeclaredDraft: From a5ecae1f71300b8055460c5b196110e96fc5820e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:04:40 -0400 Subject: [PATCH 18/22] Keep the actual member selection on its draft --- src/toad/forms/declared_views.py | 18 +++++++----------- src/toad/forms/editors.py | 2 +- src/toad/widgets/comms_command_dialog.py | 11 ++++------- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 3b21dde15..3acc0cc0f 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, make_dataclass from inspect import cleandoc -from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin +from typing import TYPE_CHECKING, Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin from formbridge.forms.ui_utils import FieldDisplayText from objectstate import DottedFieldPath, ObjectState @@ -22,6 +22,9 @@ from toad.forms.kinds import Placeholder +if TYPE_CHECKING: + from agent_comms.field_codec import MemberSelection + Metadata = TypeVar("Metadata") @@ -29,12 +32,9 @@ class MemberDraft(ABC): """Uncommitted member inputs, never a second committed union value.""" state: ObjectState + selection: MemberSelection refusal: ClassVar[type[Exception]] - @property - @abstractmethod - def declaration(self) -> object: ... - @abstractmethod def complete(self) -> object: ... @@ -46,14 +46,10 @@ class MemberNotChosen(ValueError): class UnchosenMemberDraft(MemberDraft): refusal = MemberNotChosen - def __init__(self, declaration: object) -> None: - self._declaration = declaration + def __init__(self, selection: MemberSelection) -> None: + self.selection = selection self.state = ObjectState(make_dataclass('UnchosenMember', [])()) - @property - def declaration(self) -> object: - return self._declaration - def complete(self) -> object: raise MemberNotChosen('Choose a member') diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 3f78ccacc..5b5d62fcb 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -451,7 +451,7 @@ async def select_member(self, event: Select.Changed) -> None: from toad.forms.form_view import InlineFormView event.stop() - if event.value is self.draft.declaration: + if event.value is self.draft.selection.declaration: return self.apply_button.disabled = True self.draft.state.off_resolved_changed(self.changed) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index ea5895cf9..c15e0bfee 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -73,7 +73,7 @@ def draft(self, choice: object, value: object) -> MemberDraft: return self.selection(choice).draft(self, value) def unchosen(self, member: UnchosenMember) -> MemberDraft: - return UnchosenMemberDraft(member.declaration) + return UnchosenMemberDraft(member) def member(self, member: EditorMember, value: object) -> MemberDraft: return PreparedMemberDraft(member, value, self.selected(value) is member.declaration) @@ -105,20 +105,17 @@ def arguments(self) -> dict[str, object]: class PreparedMemberDraft(DeclaredDraft, MemberDraft): refusal = MalformedRecord + selection: EditorMember def __init__(self, member: EditorMember, value: object, supplied: bool) -> None: - self.member = member + self.selection = member self.prepare_draft(tuple( TargetField.of(entry, member.current(entry, value)) if supplied else TargetField.omitted(entry) for entry in member.fields )) - @property - def declaration(self) -> object: - return self.member.declaration - def complete(self) -> object: - return self.member.complete(self.arguments()) + return self.selection.complete(self.arguments()) class CatalogField(ValueEditor, Button): From 24208379cb21ab5c733e6306dd15e34bb14b005e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:04:13 -0400 Subject: [PATCH 19/22] Keep native member selections through the union control --- src/toad/forms/declared_views.py | 9 +++------ src/toad/forms/editors.py | 16 +++++++++++----- src/toad/widgets/comms_command_dialog.py | 21 +++++++-------------- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 3acc0cc0f..e116fab01 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -58,16 +58,13 @@ class UnionMembers(ABC): """Read-only declared choices; concrete integrations own input construction.""" @abstractmethod - def choices(self) -> tuple[object, ...]: ... + def choices(self) -> tuple[MemberSelection, ...]: ... @abstractmethod - def label(self, choice: object) -> str: ... + def selected(self, value: object) -> MemberSelection: ... @abstractmethod - def selected(self, value: object) -> object: ... - - @abstractmethod - def draft(self, choice: object, value: object) -> MemberDraft: ... + def draft(self, choice: MemberSelection, value: object) -> MemberDraft: ... @abstractmethod def dialog(self, field: DeclaredField) -> ModalScreen[None]: ... diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 5b5d62fcb..ce76ab6b2 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -26,6 +26,7 @@ from textual.screen import ModalScreen from textual.widgets import Button, Checkbox, Input, Select, Static, TextArea from textual.widgets._input import InputType +from textual.widgets.select import NoSelection from toad.forms.declared_views import DeclaredField, DeclaredViews, UnionMembers, declarations from toad.forms.kinds import Multiline @@ -34,6 +35,8 @@ from toad.tone_paint import UntintedTonePaint if TYPE_CHECKING: + from agent_comms.field_codec import MemberSelection + EditorWidget = Widget else: EditorWidget = object @@ -365,7 +368,7 @@ def bind_control(self) -> None: self.paint() def value_text(self, value: object) -> str: - return self.members.label(self.members.selected(value)) + return self.members.selected(value).label def paint(self) -> None: self.label = self.value_text(self.field.value) @@ -406,8 +409,8 @@ def __init__(self, field: DeclaredField, members: UnionMembers) -> None: super().__init__() self.field, self.members = field, members choice = members.selected(field.value) - self.selector = Select( - [(members.label(member), member) for member in members.choices()], + self.selector: Select[MemberSelection] = Select( + [(member.label, member) for member in members.choices()], value=choice, allow_blank=False, id='union-member', ) self.draft = members.draft(choice, field.value) @@ -451,14 +454,17 @@ async def select_member(self, event: Select.Changed) -> None: from toad.forms.form_view import InlineFormView event.stop() - if event.value is self.draft.selection.declaration: + choice = self.selector.value + if isinstance(choice, NoSelection): + raise ValueError('A union selector requires a declared selection') + if choice == self.draft.selection: return self.apply_button.disabled = True self.draft.state.off_resolved_changed(self.changed) await self.form.remove() if not self.is_attached: return - self.draft = self.members.draft(event.value, self.field.value) + self.draft = self.members.draft(choice, self.field.value) self.form = InlineFormView(self.draft.state) await self.body.mount(self.form) self.draft.state.on_resolved_changed(self.changed) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index c15e0bfee..7a3fcde59 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -53,30 +53,23 @@ class PreparedUnion(UnionMembers, MemberDraftFactory[MemberDraft]): parameter: TargetField - def choices(self) -> tuple[object, ...]: - return tuple(member.declaration for member in self.parameter.editor_selections) + def choices(self) -> tuple[MemberSelection, ...]: + return self.parameter.editor_selections - def selection(self, choice: object) -> MemberSelection: - return next(member for member in self.parameter.editor_selections - if member.declaration is choice) - - def label(self, choice: object) -> str: - return self.selection(choice).label - - def selected(self, value: object) -> object: - return self.parameter.editor_selection(value).declaration + def selected(self, value: object) -> MemberSelection: + return self.parameter.editor_selection(value) def dialog(self, field: DeclaredField) -> CommandMemberDialog: return CommandMemberDialog(field, self) - def draft(self, choice: object, value: object) -> MemberDraft: - return self.selection(choice).draft(self, value) + def draft(self, choice: MemberSelection, value: object) -> MemberDraft: + return choice.draft(self, value) def unchosen(self, member: UnchosenMember) -> MemberDraft: return UnchosenMemberDraft(member) def member(self, member: EditorMember, value: object) -> MemberDraft: - return PreparedMemberDraft(member, value, self.selected(value) is member.declaration) + return PreparedMemberDraft(member, value, self.selected(value).declaration is member.declaration) class DeclaredDraft: From f8cf7b96ebe5ad456f0f765a1657aaa67eb99e62 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:36:54 -0400 Subject: [PATCH 20/22] Observe member subtree edits through canonical field paths --- pyproject.toml | 2 +- src/toad/forms/form_view.py | 14 +- tests/textual/test_command_native_members.py | 163 +++++++++++++++++++ 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 tests/textual/test_command_native_members.py diff --git a/pyproject.toml b/pyproject.toml index 0c7948e4b..26e6e236c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } -objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } +objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "1b67e456c493217adaa9bc1ed6e4f2841663c39c" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "95db444b536cba27081dd2c1496f71e25670a446" } diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index c9d9869f1..9c77ff735 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -218,14 +218,20 @@ def leave_state(self, event: events.Unmount) -> None: self.state.off_resolved_changed(self.values_changed) self.state.off_state_changed(self.marks_changed) + def observing_values(self, paths: set[str]) -> Iterator[ValueView]: + """Observe a changed path through its existing ancestor/descendant relation.""" + for view in self.values.values(): + if any(view.field.path.intersects_path(path) for path in paths): + yield view + def values_changed(self, paths: set[str]) -> None: - for path in paths & self.values.keys(): - self.values[path].editor.paint() + for view in self.observing_values(paths): + view.editor.paint() def marks_changed(self, paths: set[str]) -> None: differ = self.state.dirty_fields - for path in paths & self.values.keys(): - self.values[path].mark(path in differ) + for view in self.observing_values(paths): + view.mark(any(view.field.path.intersects_path(path) for path in differ)) def finish(self) -> None: """Send every edit the user has typed but not left yet.""" diff --git a/tests/textual/test_command_native_members.py b/tests/textual/test_command_native_members.py new file mode 100644 index 000000000..0ca23719d --- /dev/null +++ b/tests/textual/test_command_native_members.py @@ -0,0 +1,163 @@ +"""Actual Toad command/member dialogs; no command execution or invented state.""" + +import pytest +from textual.widgets import Button + +from agent_comms.cli_command import TargetAction +from agent_comms.cli_verbs.fork import ForkCliCommand +from agent_comms.cli_verbs.new import NewThreadCliCommand +from agent_comms.field_codec import MalformedRecord +from agent_comms.repo import Checkout +from agent_comms.thread_management import ChangedTags, GivenTags, InheritedTags +from support.ui import TextualUi +from support.workspace import Workspace +from toad.forms.editors import LineEditor, OptionalEditor, UnionEditor, ValueEditor +from toad.widgets.comms_command_dialog import CommandDialog, CommandMemberDialog + + +async def show_command(ui: TextualUi, definition: TargetAction) -> CommandDialog: + dialog = CommandDialog(definition, 'private project', await definition.prepare_fields()) + ui.app.push_screen(dialog) + await ui.until(lambda: dialog.is_attached and all( + view.editor.is_mounted for view in dialog.form.values.values() + ), why='the real command fields to mount') + return dialog + + +async def supply(ui: TextualUi, editor: ValueEditor, text: str) -> None: + control = editor + if isinstance(editor, OptionalEditor): + editor.holds_value.value = True + await ui.step() + control = editor.member + assert isinstance(control, LineEditor) + control.value = text + control.finish(explicit=True) + await ui.step() + + +async def open_member(ui: TextualUi, command: CommandDialog) -> CommandMemberDialog: + editor = command.form.values['tags'].editor + assert isinstance(editor, UnionEditor) + editor.scroll_visible(animate=False, immediate=True) + await ui.step() + assert await ui.pilot.click(editor) + await ui.until(lambda: isinstance(ui.app.screen, CommandMemberDialog), + why='the actual member transaction') + member = ui.app.screen + assert isinstance(member, CommandMemberDialog) + return member + + +@pytest.mark.parametrize('text,expected', [('', frozenset()), ('chosen', frozenset({'chosen'}))]) +async def test_new_dialog_keeps_missing_name_and_tags_distinct_from_explicit_empty( + workspace: Workspace, text: str, expected: frozenset[str] +) -> None: + binding, = NewThreadCliCommand.project_bindings(workspace.comms, Checkout(workspace.project)) + definition = TargetAction((binding,), (), NewThreadCliCommand) + async with workspace.app(session=False) as ui: + dialog = await show_command(ui, definition) + assert 'name' not in dialog.arguments() + assert 'tags' not in dialog.arguments() + assert dialog.query_one('#command-apply', Button).disabled + with pytest.raises(MalformedRecord): + definition.edited(dialog.arguments()) + await supply(ui, dialog.form.values['name'].editor, 'new-child') + assert 'tags' not in dialog.arguments() + with pytest.raises(MalformedRecord): + definition.edited(dialog.arguments()) + await supply(ui, dialog.form.values['tags'].editor, text) + completed, = definition.edited(dialog.arguments()).bound + assert completed.name == 'new-child' + assert completed.tags == GivenTags(expected) + await ui.until(lambda: not dialog.query_one('#command-apply', Button).disabled, + why='explicit name and membership to enable Apply') + # Completing a form must not create or start the command's thread. + assert 'new-child' not in workspace.comms.registry + dialog.dismiss() + + +async def test_fork_member_selection_apply_reopen_cancel_and_native_parent_refresh( + workspace: Workspace, +) -> None: + parent = workspace.thread('original-parent', 'old') + binding = ForkCliCommand.thread_binding(parent) + definition = TargetAction((binding,), (parent.name,), ForkCliCommand) + async with workspace.app(session=False) as ui: + command = await show_command(ui, definition) + assert 'parent' not in command.form.values + assert 'name' not in command.arguments() + await supply(ui, command.form.values['name'].editor, 'fork-child') + untouched, = definition.edited(command.arguments()).bound + assert untouched.tags == InheritedTags() + assert untouched.tags.for_child(frozenset({'later'})) == frozenset({'later'}) + + member = await open_member(ui, command) + given = next(choice for choice in member.members.choices() if choice.declaration is GivenTags) + member.selector.value = given + await ui.until(lambda: member.draft.selection is given and 'value' in member.form.values, + why='GivenTags text editor') + assert member.selector.value is given + await supply(ui, member.form.values['value'].editor, 'old') + assert await ui.pilot.click(member.apply_button) + await ui.until(lambda: ui.app.screen is command, why='one GivenTags commit') + assert command.state.get_resolved_value('tags') == GivenTags(frozenset({'old'})) + + member = await open_member(ui, command) + retired = member.draft.state + changed = next(choice for choice in member.members.choices() if choice.declaration is ChangedTags) + member.selector.value = changed + await ui.until(lambda: member.draft.selection is changed and set(member.form.values) == {'add', 'remove'}, + why='ChangedTags required record fields') + assert member.apply_button.disabled + assert member.draft.arguments() == {} + retired.update_parameter('value', 'retired-input') + await ui.step() + assert member.draft.arguments() == {} + assert member.apply_button.disabled + await supply(ui, member.form.values['add'].editor, '["new"]') + assert member.apply_button.disabled + await supply(ui, member.form.values['remove'].editor, '["old"]') + expected = ChangedTags(frozenset({'new'}), frozenset({'old'})) + assert member.draft.complete() == expected + assert await ui.pilot.click(member.apply_button) + await ui.until(lambda: ui.app.screen is command, why='one ChangedTags commit') + assert command.state.get_resolved_value('tags') == expected + assert set(command.state.direct_parameter_paths('tags')) == { + command.state.parameter_structure.path('tags.add'), + command.state.parameter_structure.path('tags.remove'), + } + assert 'tags.tags' not in command.state.parameters + await ui.step() + assert str(command.form.values['tags'].editor.label) == changed.label + assert command.form.values['tags'] in tuple(command.form.observing_values({'tags.add'})) + completed, = definition.edited(command.arguments()).bound + assert completed.tags == expected + assert completed.parent == parent.name + + member = await open_member(ui, command) + assert member.draft.selection.declaration is ChangedTags + assert member.draft.complete() == expected + await supply(ui, member.form.values['add'].editor, '["cancelled"]') + assert await ui.pilot.click(member.query_one('#union-cancel', Button)) + await ui.until(lambda: ui.app.screen is command, why='member transaction cancelled') + assert command.state.get_resolved_value('tags') == expected + assert 'fork-child' not in workspace.comms.registry + with pytest.raises(ValueError, match='Target-bound'): + await command.refresh_catalogs({'parent': 'not-the-captured-parent'}) + with pytest.raises(ValueError, match='not offered'): + await command.refresh_catalogs({'unoffered': 'not-a-command-field'}) + command.dismiss() + + +async def test_actual_settings_observes_a_changed_ancestor(workspace: Workspace) -> None: + from toad.screens.settings import SettingsScreen + + async with workspace.app(session=False) as ui: + settings = SettingsScreen() + ui.app.push_screen(settings) + await ui.until(lambda: settings.is_attached, why='the actual settings form') + width = settings.form.values['ui.column_width'] + assert width in tuple(settings.form.observing_values({'ui'})) + assert width in tuple(settings.form.observing_values({'ui.column_width'})) + settings.dismiss() From 3c56ae80fa8198ab5c79b0b3d1b21bd65ce1c1a5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:42:13 -0400 Subject: [PATCH 21/22] Keep member selection native and use actual editor commit controls --- src/toad/widgets/comms_command_dialog.py | 2 +- tests/textual/test_command_native_members.py | 41 +++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 7a3fcde59..f9602b8ed 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -69,7 +69,7 @@ def unchosen(self, member: UnchosenMember) -> MemberDraft: return UnchosenMemberDraft(member) def member(self, member: EditorMember, value: object) -> MemberDraft: - return PreparedMemberDraft(member, value, self.selected(value).declaration is member.declaration) + return PreparedMemberDraft(member, value, self.selected(value) == member) class DeclaredDraft: diff --git a/tests/textual/test_command_native_members.py b/tests/textual/test_command_native_members.py index 0ca23719d..ca88487d9 100644 --- a/tests/textual/test_command_native_members.py +++ b/tests/textual/test_command_native_members.py @@ -25,14 +25,9 @@ async def show_command(ui: TextualUi, definition: TargetAction) -> CommandDialog async def supply(ui: TextualUi, editor: ValueEditor, text: str) -> None: - control = editor - if isinstance(editor, OptionalEditor): - editor.holds_value.value = True - await ui.step() - control = editor.member - assert isinstance(control, LineEditor) - control.value = text - control.finish(explicit=True) + assert isinstance(editor, LineEditor) + editor.value = text + editor.commit() await ui.step() @@ -62,11 +57,19 @@ async def test_new_dialog_keeps_missing_name_and_tags_distinct_from_explicit_emp assert dialog.query_one('#command-apply', Button).disabled with pytest.raises(MalformedRecord): definition.edited(dialog.arguments()) - await supply(ui, dialog.form.values['name'].editor, 'new-child') + name = dialog.form.values['name'].editor + assert isinstance(name, OptionalEditor) + name.holds_value.value = True + await ui.step() + await supply(ui, name.member, 'new-child') assert 'tags' not in dialog.arguments() with pytest.raises(MalformedRecord): definition.edited(dialog.arguments()) - await supply(ui, dialog.form.values['tags'].editor, text) + tags = dialog.form.values['tags'].editor + assert isinstance(tags, OptionalEditor) + tags.holds_value.value = True + await ui.step() + await supply(ui, tags.member, text) completed, = definition.edited(dialog.arguments()).bound assert completed.name == 'new-child' assert completed.tags == GivenTags(expected) @@ -87,7 +90,11 @@ async def test_fork_member_selection_apply_reopen_cancel_and_native_parent_refre command = await show_command(ui, definition) assert 'parent' not in command.form.values assert 'name' not in command.arguments() - await supply(ui, command.form.values['name'].editor, 'fork-child') + name = command.form.values['name'].editor + assert isinstance(name, OptionalEditor) + name.holds_value.value = True + await ui.step() + await supply(ui, name.member, 'fork-child') untouched, = definition.edited(command.arguments()).bound assert untouched.tags == InheritedTags() assert untouched.tags.for_child(frozenset({'later'})) == frozenset({'later'}) @@ -115,9 +122,17 @@ async def test_fork_member_selection_apply_reopen_cancel_and_native_parent_refre await ui.step() assert member.draft.arguments() == {} assert member.apply_button.disabled - await supply(ui, member.form.values['add'].editor, '["new"]') + add = member.form.values['add'].editor + remove = member.form.values['remove'].editor + assert isinstance(add, OptionalEditor) + assert isinstance(remove, OptionalEditor) + add.holds_value.value = True + await ui.step() + await supply(ui, add.member, '["new"]') assert member.apply_button.disabled - await supply(ui, member.form.values['remove'].editor, '["old"]') + remove.holds_value.value = True + await ui.step() + await supply(ui, remove.member, '["old"]') expected = ChangedTags(frozenset({'new'}), frozenset({'old'})) assert member.draft.complete() == expected assert await ui.pilot.click(member.apply_button) From 82aa861b57f5252faf85a02ea5220c20b7436ae8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:45:46 -0400 Subject: [PATCH 22/22] Lock the published native topology provider --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 26e6e236c..2c3690269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } -objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "1b67e456c493217adaa9bc1ed6e4f2841663c39c" } +objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "1f859f06f70accd8c524a69b196d2837b0d79c70" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "95db444b536cba27081dd2c1496f71e25670a446" } diff --git a/uv.lock b/uv.lock index eb16acae4..24eaa449a 100644 --- a/uv.lock +++ b/uv.lock @@ -282,7 +282,7 @@ requires-dist = [ { name = "metaclass-registry", git = "https://github.com/OpenHCSDev/metaclass-registry?branch=main" }, { name = "msgpack", specifier = "==1.2.2" }, { name = "notify-py", specifier = "==0.3.43" }, - { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400" }, + { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=1f859f06f70accd8c524a69b196d2837b0d79c70" }, { name = "packaging", specifier = "==26.2" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "platformdirs", specifier = "==4.9.6" }, @@ -873,7 +873,7 @@ wheels = [ [[package]] name = "objectstate" version = "5.0.0" -source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400#b5dd9f78b9293df145d7629f21deb89b042b9400" } +source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=1f859f06f70accd8c524a69b196d2837b0d79c70#1f859f06f70accd8c524a69b196d2837b0d79c70" } dependencies = [ { name = "dill" }, { name = "pycodify" },