From 640d00863bcd2ef0d1e89ff8875ddbbd3d9c9bfe Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:30:44 -0400 Subject: [PATCH 1/7] Settings menu in two declared pages, with the input box layout as a setting The settings screen shows one tab per FormPage the top-level groups declare (Interface; Agents and shell), generated from the declarations through formbridge. Field semantics move to formbridge.forms.declared_fields; toad/forms keeps only the Textual widgets. The compact_input flag, whose consumers each branched on it, becomes the InputBox choice family (Normal, Compact, Flush); each prompt part reads its room from the chosen member. The dead prompt_message setting becomes the live input placeholder, and the three prompt symbols become settings. Choices that declared a title without TitledChoice now show it, the states_default and patch_preview member flags become overrides, and numeric help states its declared bounds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- pyproject.toml | 4 +- src/toad/core/preference_events.py | 2 +- src/toad/forms/declared_views.py | 163 ------------------ src/toad/forms/editors.py | 15 +- src/toad/forms/field_path.py | 79 --------- src/toad/forms/form_view.py | 70 ++++---- src/toad/forms/kinds.py | 15 -- src/toad/keymap.py | 2 +- src/toad/preferences.py | 73 +++++--- src/toad/screens/settings.py | 47 +++-- src/toad/setting_choices.py | 80 ++++++++- src/toad/setting_declarations.py | 2 +- src/toad/setting_effects.py | 19 +- src/toad/widgets/comms_command_dialog.py | 4 +- src/toad/widgets/prompt.py | 47 ++--- src/toad/widgets/session_sort.py | 4 +- src/toad/widgets/tool_call.py | 3 +- tests/model/test_saved_settings.py | 2 +- ..._application_actions_in_the_running_app.py | 8 +- tests/textual/test_forms.py | 6 +- .../test_quit_with_unsaved_settings.py | 4 +- tests/textual/test_settings_screen.py | 16 +- uv.lock | 4 +- 23 files changed, 275 insertions(+), 394 deletions(-) delete mode 100644 src/toad/forms/declared_views.py delete mode 100644 src/toad/forms/field_path.py delete mode 100644 src/toad/forms/kinds.py diff --git a/pyproject.toml b/pyproject.toml index 564a0629e..1295e3675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "metaclass-registry", "agent-client-protocol==0.12.1", "ui-theme", - "formbridge==0.2.0", + "formbridge", "objectstate==5.0.0", ] @@ -43,7 +43,7 @@ ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = "main" } 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" } +formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } 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/core/preference_events.py b/src/toad/core/preference_events.py index d3fb98c38..7736b3ccd 100644 --- a/src/toad/core/preference_events.py +++ b/src/toad/core/preference_events.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from toad.forms.declared_views import DeclaredField +from formbridge.forms.declared_fields import DeclaredField from .events import CoreEvent diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py deleted file mode 100644 index 394caf1ec..000000000 --- a/src/toad/forms/declared_views.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Widget families whose members say which declarations they show.""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable, Iterator -from dataclasses import dataclass -from inspect import cleandoc -from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin - -from formbridge.forms.ui_utils import FieldDisplayText -from objectstate import DottedFieldPath, ObjectState -from python_introspect import ( - ParameterInfo, - declared_field_label, - optional_member_type, - resolve_annotated, - resolve_optional, -) -from python_introspect.validation import validate_annotation_value - -from toad.forms.kinds import Placeholder - -Metadata = TypeVar("Metadata") - - -class DeclaredViews: - """A family of widget classes, each registered under what it shows. - - A class directly below this one starts a family. A member names the - declarations it shows in its own body (`shows = (int,)`), so adding a kind - of field is adding a class: nothing lists the members and nothing chooses - between them by testing types. - - Textual follows the first DOM base for native focus and CSS. A pure editor - capability may precede that base so its cooperative initializer binds the - field before the native control consumes it. - """ - - shows: ClassVar[tuple[object, ...]] = () - shown_by: ClassVar[dict[object, Callable]] - - def __init_subclass__(cls, **kwargs: object) -> None: - super().__init_subclass__(**kwargs) - if DeclaredViews in cls.__bases__: - cls.shown_by = {} - for declaration in vars(cls).get("shows", ()): - cls.shown_by[declaration] = cls - - @classmethod - def showing(cls, declarations: Iterable[object]) -> Callable[..., Self]: - """The member registered under the first of these declarations that has one.""" - declarations = tuple(declarations) - for declaration in declarations: - if declaration in cls.shown_by: - return cls.shown_by[declaration] - raise LookupError(f"No {cls.__name__} shows any of {declarations!r}") - - -def declarations(annotation: object) -> Iterator[object]: - """What an annotation declares, most specific first. - - `Annotated` metadata comes before the type it annotates, `T | None` is - `Optional`, and a class is followed by its bases. - """ - if get_origin(annotation) is Annotated: - base, *metadata = get_args(annotation) - for item in metadata: - yield from type(item).__mro__[:-1] - yield from declarations(base) - elif optional_member_type(annotation) is not None: - yield Optional - else: - declared = get_origin(annotation) or annotation - yield from declared.__mro__[:-1] if isinstance(declared, type) else (declared,) - - -@dataclass(frozen=True) -class DeclaredField: - """One field of an ObjectState: where its value lives and how it was declared.""" - - state: ObjectState - path: DottedFieldPath - - @property - def declaration(self) -> ParameterInfo: - return self.state.parameter_structure.declarations[self.path.value] - - @property - def annotation(self) -> object: - return self.declaration.param_type - - @property - def owner_type(self) -> type: - """The class of the resolved record containing this field. - - ObjectState's declaration owner may instead be a function. Record - serialization needs the containing value's actual class, including - the delegate returned by to_object at the root. - """ - parent = self.path.parent.value - owner = self.state.get_resolved_value(parent) if parent else self.state.to_object() - return type(owner) - - def metadata(self, kind: type[Metadata]) -> Metadata | None: - """Metadata of the bound field, including an optional member's metadata.""" - for annotation in (self.annotation, resolve_optional(resolve_annotated(self.annotation))): - if get_origin(annotation) is Annotated: - for item in get_args(annotation)[1:]: - if isinstance(item, kind): - return item - return None - - @property - def value(self) -> object: - return self.state.get_resolved_value(self.path.value) - - @property - def default(self) -> object: - return self.declaration.default_value - - @property - def has_default(self) -> bool: - return not self.declaration.is_required - - @property - def placeholder(self) -> str: - hint = self.metadata(Placeholder) - return hint.text if hint is not None else "" - - @property - def label(self) -> str: - return ( - declared_field_label(self.annotation) - or FieldDisplayText.from_field_name(self.declaration.name).display_name - ) - - @property - def help(self) -> str: - return cleandoc(self.declaration.description or "") - - @property - def member(self) -> DeclaredField: - """The same field as declared without `None`.""" - return OptionalMemberField(self.state, self.path) - - def validate(self, value: object) -> None: - """Require this field's declared type and constraints before edit or save.""" - # ObjectState takes any value for a field of the root object and only - # fails later, when the object is rebuilt (ObjectState issue #47). - validate_annotation_value(self.annotation, value, path=self.label) - - def edit(self, value: object) -> None: - """Give the field a value accepted by its declaration.""" - self.validate(value) - self.state.update_parameter(self.path.value, value) - - -class OptionalMemberField(DeclaredField): - """The member editor reads a narrower annotation, not a copied declaration.""" - - @property - def annotation(self) -> object: - return optional_member_type(resolve_annotated(super().annotation)) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 6cd067253..63293b4af 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -23,8 +23,8 @@ from textual.widgets import Checkbox, Input, Select, TextArea from textual.widgets._input import InputType -from toad.forms.declared_views import DeclaredField, DeclaredViews, declarations -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, declarations +from formbridge.forms.declared_fields import Multiline from toad.forms.presentation import FormCheckbox, FormInput, FormSelectCurrent, FramedControl from toad.widget_view import WidgetView from toad.tone_paint import UntintedTonePaint @@ -43,9 +43,6 @@ class ValueEditor(DeclaredViews, EditorWidget): control is initialized: `class BoolEditor(ValueEditor, Checkbox)`. """ - states_default: ClassVar[bool] = True - """Whether the field's help should end with its default value.""" - field: DeclaredField def __init__(self, field: DeclaredField) -> None: @@ -76,6 +73,10 @@ def value_text(self, value: object) -> str: """How a value of this field reads.""" return self.field.placeholder if value is None else str(value) + def default_note(self) -> str: + """The line ending the field's help that names its default value.""" + return f"\ndefault: {self.value_text(self.field.default)}" if self.field.has_default else "" + def send(self, value: object) -> None: try: self.field.edit(value) @@ -171,7 +172,9 @@ class MultilineEditor(ValueEditor, UntintedTonePaint, FramedControl, TextArea): height = Scalar.from_number(10) padding = Spacing(0, 1, 0, 1) - states_default = False + def default_note(self) -> str: + """A text block's default is too long to restate under its help.""" + return "" def control_arguments(self) -> dict[str, object]: return {"text": str(self.field.value or "")} diff --git a/src/toad/forms/field_path.py b/src/toad/forms/field_path.py deleted file mode 100644 index f538a2035..000000000 --- a/src/toad/forms/field_path.py +++ /dev/null @@ -1,79 +0,0 @@ -"""A field of a dataclass tree, named through the declarations instead of a string.""" - -from __future__ import annotations - -from dataclasses import fields, is_dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from objectstate import ObjectState - -from python_introspect import UnifiedParameterAnalyzer, resolve_annotated, resolve_optional - -from toad.forms.declared_views import DeclaredField - - -class FieldPath: - """One field of a dataclass tree: `FieldPath(Settings).sidebar.hide`. - - Each step is checked against the dataclass fields when the path is built, - so a path that exists names a declared field. `str(path)` is the dotted - name ObjectState keeps the field under. - """ - - __slots__ = ("_root", "_names") - - def __init__(self, root: type, names: tuple[str, ...] = ()) -> None: - self._root, self._names = root, names - - def __getattr__(self, name: str) -> FieldPath: - owner = declared_type(self) - if not is_dataclass(owner) or name not in {field.name for field in fields(owner)}: - raise AttributeError(f"{owner!r} declares no field {name!r}") - return FieldPath(self._root, (*self._names, name)) - - def __str__(self) -> str: - return ".".join(self._names) - - def __repr__(self) -> str: - return f"FieldPath({self._root.__name__}, {str(self)!r})" - - def __eq__(self, other: object) -> bool: - return ( - isinstance(other, FieldPath) - and (self._root, self._names) == (other._root, other._names) - ) - - def __hash__(self) -> int: - return hash((self._root, self._names)) - - def bind(self, state: ObjectState) -> DeclaredField: - """Bind this declaration to the field ObjectState declares at this path.""" - if not issubclass(state.type_for_path(""), self._root): - raise TypeError(f"{self!r} does not name a field of this ObjectState") - return DeclaredField(state, state.parameter_structure.path(str(self))) - - def matches(self, field: DeclaredField) -> bool: - """Whether this declaration names the bound field, including its root type.""" - return ( - issubclass(field.state.type_for_path(""), self._root) - and field.path.parts == self._names - ) - - -def parent(path: FieldPath) -> FieldPath: - return FieldPath(path._root, path._names[:-1]) - - -def declared_annotation(path: FieldPath) -> object: - """The field's annotation as written, `Annotated` metadata included.""" - if not path._names: - return path._root - return UnifiedParameterAnalyzer.analyze(declared_type(parent(path)))[ - path._names[-1] - ].param_type - - -def declared_type(path: FieldPath) -> object: - """The type a value of the field has, without `Annotated` and `Optional`.""" - return resolve_annotated(resolve_optional(resolve_annotated(declared_annotation(path)))) diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 8ab4e9aef..de7e2f8e5 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -6,7 +6,7 @@ from formbridge.forms.parameter_form_service import ParameterFormService from formbridge.forms.parameter_info_types import DirectDataclassInfo, ParameterInfo -from objectstate import DottedFieldPath, ObjectState +from objectstate import ObjectState from textual import containers, events, lazy from textual.app import ComposeResult from textual.content import Content @@ -17,9 +17,8 @@ from rich.style import Style from ui_theme.scheme import BackgroundColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour -from toad.forms.declared_views import DeclaredField, DeclaredViews +from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, FormPage, form_pages from toad.forms.editors import ValueEditor -from toad.forms.field_path import FieldPath from toad.widget_view import AUTO, FocusWithinView, WidgetView @@ -48,8 +47,7 @@ def paint_foreground(self): class FieldView(WidgetView, containers.VerticalGroup, DeclaredViews): """One parameter of a form, shown the way its formbridge kind asks for.""" - def __init__(self, field: DeclaredField, - included: frozenset[DottedFieldPath] | None = None) -> None: + def __init__(self, field: DeclaredField) -> None: super().__init__() self.field = field @@ -58,19 +56,14 @@ def margin(self) -> Spacing: return Spacing(0, 0, 0 if self.last_child else 1, 0) @staticmethod - def of(state: ObjectState, parent: str = "", - included: frozenset[DottedFieldPath] | None = None) -> list[FieldView]: + def of(state: ObjectState, parent: str = "") -> list[FieldView]: """Show ObjectState's direct children; FormBridge supplies each field's kind. ObjectState already extracted this topology. Reading it does not rebuild a second form tree or re-analyze the held object's values. """ - views = [] - 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)) - return views + return [FieldView.showing(type(parameter).__mro__)(DeclaredField(state, path)) + for path, parameter in ParameterFormService().parameters_for_state(state, parent)] def values(self) -> Iterator[ValueView]: """The views below this one that hold a single value.""" @@ -88,15 +81,10 @@ class ValueView(FieldView): CHANGED_MARK = " ●" - def __init__(self, field: DeclaredField, - included: frozenset[DottedFieldPath] | None = None) -> None: - super().__init__(field, included) + def __init__(self, field: DeclaredField) -> None: + super().__init__(field) self.editor = ValueEditor.declared_for(field) self.title = FieldTitle(field.label) - self.search_text = " ".join( - DeclaredField(field.state, step).label - for step in field.state.parameter_structure.ancestors(field.path) - ).lower() def compose(self) -> ComposeResult: yield self.title @@ -105,17 +93,16 @@ def compose(self) -> ComposeResult: def description(self) -> Content: help = Content.from_markup(self.field.help) - if not self.editor.states_default or not self.field.has_default: - return help - default = self.editor.value_text(self.field.default) - return Content.assemble(help, (f"\ndefault: {default}", "$text-secondary")) + if bounds := self.field.bounds_text: + help = Content.assemble(help, (f"\naccepts: {bounds}", "$text-secondary")) + return Content.assemble(help, (self.editor.default_note(), "$text-secondary")) def values(self) -> Iterator[ValueView]: yield self def filter(self, term: str) -> bool: - self.display = term in self.search_text - return term in self.search_text + self.display = term in self.field.search_text + return bool(self.display) def mark(self, changed: bool) -> None: """Say on the title whether the value differs from the saved one.""" @@ -141,10 +128,9 @@ def border(self) -> Edges: colours.colour(ForegroundColour).multiply_alpha(0.2)) return Edges(*(("tall", colour),) * 4) - def __init__(self, field: DeclaredField, - included: frozenset[DottedFieldPath] | None = None) -> None: - super().__init__(field, included) - self.parts = FieldView.of(field.state, field.path.value, included) + def __init__(self, field: DeclaredField) -> None: + super().__init__(field) + self.parts = FieldView.of(field.state, field.path.value) def compose(self) -> ComposeResult: yield GroupTitle(self.field.label) @@ -173,24 +159,28 @@ class FormView(WidgetView, containers.VerticalScroll, can_focus=False): height = None overflow_y = "scroll" - def __init__(self, state: ObjectState, *, paths: tuple[FieldPath, ...] | None = None, - focus: DottedFieldPath | None = None) -> None: + def __init__(self, state: ObjectState, parts: list[FieldView]) -> None: super().__init__() self.state = state - included = (None if paths is None else frozenset( - step for path in paths - for step in state.parameter_structure.ancestors(path.bind(state).path) - )) - self.parts = FieldView.of(state, included=included) + self.parts = parts self.values = { view.field.path.value: view for part in self.parts for view in part.values() } - if focus is not None: - editor = self.values[focus.value].editor - editor.listen(events.Mount, lambda _: editor.focus()) self.listen(events.Mount, self.follow_state) self.listen(events.Unmount, self.leave_state) + @classmethod + def whole(cls, state: ObjectState) -> FormView: + """Every shown field of the state on one form.""" + return cls(state, FieldView.of(state)) + + @classmethod + def pages(cls, state: ObjectState) -> dict[FormPage, FormView]: + """One form per page the top-level fields declare, in declaration order.""" + parts = {part.field.path: part for part in FieldView.of(state)} + return {page: cls(state, [parts[path] for path in paths]) + for page, paths in form_pages(state, parts).items()} + def follow_state(self, event: events.Mount) -> None: lazy.Reveal.reveal(self, list(self.parts)) self.state.on_resolved_changed(self.values_changed) diff --git a/src/toad/forms/kinds.py b/src/toad/forms/kinds.py deleted file mode 100644 index 3dccc9e53..000000000 --- a/src/toad/forms/kinds.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Field kinds a type alone does not say, declared as `Annotated` metadata.""" - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Placeholder: - """Text for an empty field, not an offered or persisted value.""" - - text: str - - -@dataclass(frozen=True) -class Multiline: - """Text of several lines: `Annotated[str, Multiline()]`.""" diff --git a/src/toad/keymap.py b/src/toad/keymap.py index 642acb12f..1748e5176 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: from toad.app import ToadApp - from toad.forms.declared_views import DeclaredField + from formbridge.forms.declared_fields import DeclaredField @cache diff --git a/src/toad/preferences.py b/src/toad/preferences.py index aed4f1cbf..e745dffc1 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -20,16 +20,14 @@ from agent_comms.field_codec import FieldCodec, WireName from agent_comms.locked_store import FilesRequest from agent_comms.typed_table import ExternalStore -from annotated_types import Ge +from annotated_types import Ge, MinLen from objectstate import DottedFieldPath, ObjectState, set_base_config_type from pydantic import JsonValue from python_introspect import AnnotatedDataclassValidationMixin, FieldLabel from toad import atomic, paths from toad import setting_effects as effects -from toad.forms.declared_views import DeclaredField -from toad.forms.field_path import FieldPath -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import DeclaredField, FieldPath, FormPage, Multiline from toad.key_modes import KeyMode, Standard from toad.keymap import Keymap from toad.patch import DiffSettings @@ -37,7 +35,9 @@ AlwaysSessionBar, BlurNotification, ColumnAlignment, + CompactInputBox, Expansion, + InputBox, FailExpansion, LoadingStyle, LeftColumnAlignment, @@ -55,6 +55,34 @@ from toad.app import ToadApp +@dataclass(frozen=True) +class InputSettings(AnnotatedDataclassValidationMixin): + box: Annotated[ + type[InputBox], FieldLabel("Layout"), FamilyChoices(InputBox), effects.prompt_layout, + ] = CompactInputBox + """Normal draws a padded border around the prompt. Compact keeps it flush with the conversation. Flush also removes the spacing around the label and the info bar.""" + + placeholder: Annotated[str, FieldLabel("Placeholder"), effects.prompt_text] = ( + "How can I help you today?" + ) + """Text shown in the empty prompt of an agent session.""" + + agent_symbol: Annotated[ + str, FieldLabel("Message symbol"), MinLen(1), effects.prompt_text, + ] = "❯" + """Shown before a one-line message to the agent.""" + + multiline_symbol: Annotated[ + str, FieldLabel("Multi-line message symbol"), MinLen(1), effects.prompt_text, + ] = "☰" + """Shown before a message of several lines or one holding a code fence.""" + + shell_symbol: Annotated[ + str, FieldLabel("Shell command symbol"), MinLen(1), effects.prompt_text, + ] = "$" + """Shown before a shell command.""" + + @dataclass(frozen=True) class UiSettings(AnnotatedDataclassValidationMixin): wheel_scroll_rows: Annotated[ @@ -84,14 +112,10 @@ class UiSettings(AnnotatedDataclassValidationMixin): keymap: Keymap = field(default_factory=Keymap) """Keyboard overrides, edited in the keybinding menu.""" - prompt_message: Annotated[str, FieldLabel("Prompt message")] = "How can I help you today?" - """Text shown as placeholder in prompt text area""" - - compact_input: Annotated[ - bool, FieldLabel("Compact text input?"), effects.visibility, - WireName("compact-input"), - ] = False - """Remove border and margin around the text area for additional space""" + input: Annotated[InputSettings, FieldLabel("Input box")] = field( + default_factory=InputSettings + ) + """The prompt you type messages and shell commands into.""" sessions_bar: Annotated[ type[SessionBar], @@ -259,52 +283,59 @@ async def collect(self, operation: Callable[[], Awaitable[None]]) -> None: await operation() +INTERFACE = FormPage("Interface") +"""How Toad looks and responds: layout, input box, sidebar, notifications and diffs.""" + +SESSIONS = FormPage("Agents and shell") +"""What Toad does in a session: agent output, tool calls, the shell, the launcher and usage data.""" + + @dataclass(frozen=True) class ToadSettings(AnnotatedDataclassValidationMixin): anon_id: str = field(default="", metadata={"ui_hidden": True}) """Anonymous installation ID""" - ui: Annotated[UiSettings, FieldLabel("User interface settings")] = field( + ui: Annotated[UiSettings, FieldLabel("User interface settings"), INTERFACE] = field( default_factory=UiSettings ) """The following settings allow you to customize the look and feel of the User Interface.""" notifications: Annotated[ - NotificationsSettings, FieldLabel("Notification (toasts) settings") + NotificationsSettings, FieldLabel("Notification (toasts) settings"), INTERFACE ] = field(default_factory=NotificationsSettings) """Customize how Toad displays notifications""" - sidebar: Annotated[SidebarSettings, FieldLabel("Sidebar settings")] = field( + sidebar: Annotated[SidebarSettings, FieldLabel("Sidebar settings"), INTERFACE] = field( default_factory=SidebarSettings ) """Customize how the sidebar is displayed.""" - agent: Annotated[AgentSettings, FieldLabel("Agent settings")] = field( + agent: Annotated[AgentSettings, FieldLabel("Agent settings"), SESSIONS] = field( default_factory=AgentSettings ) """Customize how you interact with agents""" - tools: Annotated[ToolsSettings, FieldLabel("Tool call settings")] = field( + tools: Annotated[ToolsSettings, FieldLabel("Tool call settings"), SESSIONS] = field( default_factory=ToolsSettings ) """Customize how Toad displays agent tool calls""" - shell: Annotated[ShellSettings, FieldLabel("Shell settings")] = field( + shell: Annotated[ShellSettings, FieldLabel("Shell settings"), SESSIONS] = field( default_factory=ShellSettings ) """Customize shell interactions.""" - diff: Annotated[DiffSettings, FieldLabel("Diff view settings")] = field( + diff: Annotated[DiffSettings, FieldLabel("Diff view settings"), INTERFACE] = field( default_factory=DiffSettings ) """Customize how diffs are displayed.""" - launcher: Annotated[LauncherSettings, FieldLabel("Launcher settings")] = field( + launcher: Annotated[LauncherSettings, FieldLabel("Launcher settings"), SESSIONS] = field( default_factory=LauncherSettings, metadata={"ui_hidden": True} ) """Customize the launcher""" - statistics: Annotated[StatisticsSettings, FieldLabel("Data collection")] = field( + statistics: Annotated[StatisticsSettings, FieldLabel("Data collection"), SESSIONS] = field( default_factory=StatisticsSettings ) """Preferences regarding data collection.""" diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 9c8aa59c7..ce5fee38b 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -1,25 +1,26 @@ -"""The settings form: every setting the user may edit, searched by its label.""" +"""The settings form: every setting the user may edit, one tab per declared page, searched by label.""" from __future__ import annotations +from collections import ChainMap from collections.abc import Iterable from textual import containers, events, getters from textual.app import ComposeResult from textual.binding import ModeBinding from textual.document._editing import EditingMode -from textual.widgets import Input +from textual.widgets import ContentSwitcher, Input, Tab, Tabs from toad.widgets.footer import ToadFooter from textual.geometry import Spacing from textual.color import Color from toad.app import ScreenWidth, ToadApp from toad.forms.editors import SelectEditor -from toad.forms.form_view import FormView +from toad.forms.form_view import FormView, ValueView from toad.forms.presentation import FormInput, FormModal from toad.screens.modal import DismissibleScreen from toad.widget_actions import PriorityActions -from toad.widget_view import WidgetView +from toad.widget_view import FILL, WidgetView from toad.setting_choices import SchemeName @@ -52,6 +53,12 @@ class SettingsSearch(WidgetView, containers.VerticalGroup): padding = Spacing(1, 0, 1, 0) +class SettingsPages(WidgetView, ContentSwitcher): + """The page of settings whose tab is active; the others stay mounted and hidden.""" + + height = FILL + + class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None]): align_horizontal = "right" align_vertical = "top" @@ -60,7 +67,10 @@ class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None]): def __init__(self) -> None: super().__init__() self.search = FormInput(placeholder="Search settings") - self.form = FormView(self.app.saved_settings.state) + self.pages = FormView.pages(self.app.saved_settings.state) + for number, form in enumerate(self.pages.values()): + form.id = f"settings-page-{number}" + self.listen(Tabs.TabActivated, self.page_chosen) self.search.listen(Input.Changed, self.searched) self.listen(events.Mount, self.opened) self.listen(events.Unmount, self.closed) @@ -69,22 +79,39 @@ def compose(self) -> ComposeResult: with SettingsPanel(): with SettingsSearch(): yield self.search - yield self.form + yield Tabs(*(Tab(page.title, id=form.id) for page, form in self.pages.items())) + with SettingsPages(initial=next(iter(self.pages.values())).id): + yield from self.pages.values() yield ToadFooter() def opened(self, event: events.Mount) -> None: - self.app.saved_settings.open_edits.append(self.form.finish) + self.app.saved_settings.open_edits.append(self.finish) def closed(self, event: events.Unmount) -> None: - self.app.saved_settings.open_edits.remove(self.form.finish) + self.app.saved_settings.open_edits.remove(self.finish) + + @property + def values(self) -> ChainMap[str, ValueView]: + """Every single-value view on every page, by dotted path.""" + return ChainMap(*(form.values for form in self.pages.values())) + + def page_chosen(self, message: Tabs.TabActivated) -> None: + message.stop() + self.query_one(SettingsPages).current = message.tab.id + + def finish(self) -> None: + """Send every edit typed on any page but not left yet.""" + for form in self.pages.values(): + form.finish() def searched(self, message: Input.Changed) -> None: message.stop() - self.form.filter(message.value) + for form in self.pages.values(): + form.filter(message.value) def dismiss_control(self) -> None: """Leave with every edit sent; the settings are saved once the screen is gone.""" - self.form.finish() + self.finish() super().dismiss_control() def focus_search(self) -> None: diff --git a/src/toad/setting_choices.py b/src/toad/setting_choices.py index cba8fbfed..e40e8d3bb 100644 --- a/src/toad/setting_choices.py +++ b/src/toad/setting_choices.py @@ -3,16 +3,20 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable from dataclasses import dataclass from typing import ClassVar, cast from random import choice from threading import Lock +from textual.color import Color, TRANSPARENT from textual.content import Content +from textual.css._style_properties import Edges from textual.css.types import AlignHorizontal +from textual.geometry import Spacing from textual.widget import Widget from toad.widgets.future_text import FutureText -from agent_comms.declared_choice import DeclaredChoice +from agent_comms.declared_choice import DeclaredChoice, TitledChoice from toad.platform import DesktopService, host @@ -26,15 +30,20 @@ class SchemeName: class Expansion(DeclaredChoice, affix="Expansion"): - patch_preview = True - @classmethod @abstractmethod def should_expand(cls, status) -> bool: ... + @classmethod + def expands_preview(cls, status, preview_fits: Callable[[], bool]) -> bool: + """A completed call opens to show its patch preview when the preview fits.""" + return status.completed and preview_fits() + class NeverExpansion(Expansion): - patch_preview = False + @classmethod + def expands_preview(cls, status, preview_fits: Callable[[], bool]) -> bool: + return False @classmethod def should_expand(cls, status) -> bool: @@ -47,7 +56,7 @@ def should_expand(cls, status) -> bool: return True -class SuccessExpansion(Expansion): +class SuccessExpansion(TitledChoice, Expansion): title = "Success only" @classmethod @@ -55,7 +64,7 @@ def should_expand(cls, status) -> bool: return status.completed -class FailExpansion(Expansion): +class FailExpansion(TitledChoice, Expansion): title = "Fail only" @classmethod @@ -85,7 +94,7 @@ def shown(cls, count: int) -> bool: return True -class MultipleSessionBar(SessionBar): +class MultipleSessionBar(TitledChoice, SessionBar): title = "When there are multiple sessions" @classmethod @@ -131,7 +140,7 @@ def enabled(cls, focused: bool) -> bool: return False -class BlurNotification(NotificationPolicy): +class BlurNotification(TitledChoice, NotificationPolicy): title = "When app is not focused" @classmethod @@ -161,6 +170,61 @@ class HiddenScrollbar(Scrollbar): pass +class InputBox(DeclaredChoice, affix="InputBox"): + """How much room the prompt takes around the text being typed. + + The base is the compact prompt; a member overrides only the room it changes. + """ + + label_padding: ClassVar[Spacing] = Spacing(0, 1, 0, 1) + agent_info_padding: ClassVar[Spacing] = Spacing(0, 1, 0, 1) + frame_margin: ClassVar[Spacing] = Spacing(0, 0, 0, 0) + frame_padding: ClassVar[Spacing] = Spacing(0, 0, 0, 0) + + @classmethod + def frame_border(cls, colour: Color) -> Edges: + """The edges drawn around the text and its label.""" + return Edges(*(("", TRANSPARENT),) * 4) + + @classmethod + def info_bar_margin(cls, footer: bool) -> Spacing: + """Room beside the info bar; it lines up with the footer's keys while one is shown.""" + space = int(footer) + return Spacing(0, space, 0, space) + + +class NormalInputBox(TitledChoice, InputBox): + """The classic prompt: a bordered, padded box above the info bar.""" + + title = "Normal (bordered and padded)" + frame_margin = Spacing(1, 1, 0, 1) + frame_padding = Spacing(0, 1, 0, 0) + + @classmethod + def frame_border(cls, colour: Color) -> Edges: + return Edges(*(("round", colour),) * 4) + + @classmethod + def info_bar_margin(cls, footer: bool) -> Spacing: + return Spacing(0, 2, 0, 2) + + +class CompactInputBox(TitledChoice, InputBox): + title = "Compact (no border)" + + +class FlushInputBox(TitledChoice, InputBox): + """No room at all: the label, the text and the info bar touch the screen edge.""" + + title = "Flush (no border or spacing)" + label_padding = Spacing(0, 1, 0, 0) + agent_info_padding = Spacing(0, 0, 0, 0) + + @classmethod + def info_bar_margin(cls, footer: bool) -> Spacing: + return Spacing(0, 0, 0, 0) + + class ColumnAlignment(DeclaredChoice, affix="ColumnAlignment"): """Place a width-constrained conversation using Textual's container layout.""" diff --git a/src/toad/setting_declarations.py b/src/toad/setting_declarations.py index 56c3e36e6..67371e2aa 100644 --- a/src/toad/setting_declarations.py +++ b/src/toad/setting_declarations.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from toad.app import ToadApp - from toad.forms.declared_views import DeclaredField + from formbridge.forms.declared_fields import DeclaredField @dataclass(frozen=True) diff --git a/src/toad/setting_effects.py b/src/toad/setting_effects.py index ac20bed94..22261c26c 100644 --- a/src/toad/setting_effects.py +++ b/src/toad/setting_effects.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import replace from typing import TYPE_CHECKING -from toad.forms.declared_views import DeclaredField +from formbridge.forms.declared_fields import DeclaredField from toad.setting_widget import SettingVisibility from toad.widgets.history_anchor import HistoryWindow @@ -69,3 +69,20 @@ def sessions_bar(app: ToadApp, field: DeclaredField) -> None: @Effect def blink_title(app: ToadApp, field: DeclaredField) -> None: app.terminal_attention.update() + + +@Effect +def prompt_layout(app: ToadApp, field: DeclaredField) -> None: + """Prompt parts read their room from the input box setting when laid out.""" + from toad.widgets.prompt import Prompt + for prompt in Effect.open_widgets(app, Prompt): + for part in prompt.walk_children(): + part.refresh(layout=True) + prompt.refresh(layout=True) + + +@Effect +def prompt_text(app: ToadApp, field: DeclaredField) -> None: + from toad.widgets.prompt import Prompt + for prompt in Effect.open_widgets(app, Prompt): + prompt.update_prompt() diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 6be0dc43c..e247ef8a4 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -18,7 +18,7 @@ from textual.widgets import Button, Checkbox, Static from toad.forms.editors import ValueEditor from toad.forms.form_view import InlineFormView -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import Multiline from toad.screens.modal import ModalScreen, DismissibleScreen from toad.widget_actions import DismissAction, WidgetAction from textual.document._editing import EditingMode @@ -167,7 +167,7 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi declarations.append((parameter.name, Annotated[compile_decoder(parameter.annotation).editor_annotation, *hints], field(default=parameter.editor_default))) self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) - self.form = InlineFormView(self.state) + self.form = InlineFormView.whole(self.state) self.form.id = 'command-fields' def compose(self) -> ComposeResult: diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 0ab941295..3e7a4574a 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -28,7 +28,7 @@ from textual.widgets import Label, OptionList, TextArea from textual.widgets.option_list import Option from textual.widgets.text_area import Selection -from ui_theme.scheme import PrimaryColour, PromptToneColour +from ui_theme.scheme import MutedColour, PrimaryColour, PromptToneColour from toad import messages from toad.clipboard_image import read_clipboard_png, save_clipboard_png @@ -51,7 +51,7 @@ from toad.widget_actions import ( DismissibleControl, HiddenActions, LocalMethodKeys, NativeMethodKeys, ) -from toad.widget_view import AUTO, WidgetView +from toad.widget_view import AUTO, FocusWithinView, WidgetView from toad.widgets.condensed_path import CondensedPath from toad.widgets.highlighted_textarea import HighlightedTextArea from toad.widgets.model_switcher import ModelSwitcher @@ -104,11 +104,7 @@ class AgentInfo( @property def padding(self): - return ( - super().padding - if self.app.settings.ui.compact_input - else Spacing(0, 1, 0, 1) - ) + return self.app.settings.ui.input.box.agent_info_padding @property def primary_activation(self): @@ -152,25 +148,34 @@ class InfoBar( @property def margin(self): ui = self.app.settings.ui - space = 0 if ui.compact_input else int(ui.footer) - return Spacing(0, space, 0, space) + return ui.input.box.info_bar_margin(ui.footer) class PromptLabel(WidgetView, Label): width = AUTO - AI = Content.styled("❯", "$text-secondary") - SHELL = Content.styled("$", "$text-primary") - MULTILINE = Content.styled("☰", "$text-secondary") @property def padding(self): - return Spacing(0, 1, 0, 0 if self.app.settings.ui.compact_input else 1) + return self.app.settings.ui.input.box.label_padding def render(self): return self.query_ancestor(Prompt).label_glyph() -class PromptContainer(WidgetView, containers.HorizontalGroup): +class PromptContainer(FocusWithinView, containers.HorizontalGroup): + @property + def margin(self): + return self.app.settings.ui.input.box.frame_margin + + @property + def padding(self): + return self.app.settings.ui.input.box.frame_padding + + @property + def border(self): + colour = self.app.colours.colour(PrimaryColour if self.has_focus_within else MutedColour) + return self.app.settings.ui.input.box.frame_border(colour) + def on_mouse_down(self, event: events.MouseUp) -> None: self.query_ancestor(Prompt).focus() @@ -801,7 +806,7 @@ def update_prompt(self): "Enter shell command\t[r]▌esc▐[/r] prompt mode" ).expand_tabs(8) if shell - else "" + else self.app.settings.ui.input.placeholder ) editor.highlight_language = "shell" if shell else "markdown" self.prompt_label.refresh() @@ -860,12 +865,14 @@ def suggest(self, suggestion: str) -> None: self.prompt_text_area.suggestion = suggestion[len(self.text) :] def label_glyph(self) -> Content: + symbols = self.app.settings.ui.input if self.shell_mode: - return PromptLabel.SHELL - return ( - PromptLabel.MULTILINE + return Content.styled(symbols.shell_symbol, "$text-primary") + return Content.styled( + symbols.multiline_symbol if "\n" in self.text or "```" in self.text - else PromptLabel.AI + else symbols.agent_symbol, + "$text-secondary", ) def compose_completions(self) -> ComposeResult: @@ -968,7 +975,7 @@ def update_prompt(self) -> None: self.prompt_label.refresh() def label_glyph(self) -> Content: - return PromptLabel.AI + return Content.styled(self.app.settings.ui.input.agent_symbol, "$text-secondary") def compose_delivery_controls(self) -> ComposeResult: return () diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index dd3798021..8d4f22210 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -19,9 +19,9 @@ from toad.widget_actions import Activates from textual.geometry import Offset -from toad.forms.field_path import FieldPath +from formbridge.forms.declared_fields import FieldPath from toad.widgets.comms_menu import ContextMenu -from toad.forms.kinds import Placeholder +from formbridge.forms.declared_fields import Placeholder from toad.preferences import SETTINGS from toad.setting_declarations import FamilyChoices diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index d48c9cd0b..091601e91 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -210,8 +210,7 @@ def check_expand(self) -> None: return tool_call_expand = self.app.settings.tools.expand status = tool_call - if (status.completed and tool_call_expand.patch_preview - and self.output.preview_fits()): + if tool_call_expand.expands_preview(status, self.output.preview_fits): self._auto_expanded = True self.expanded = True return diff --git a/tests/model/test_saved_settings.py b/tests/model/test_saved_settings.py index 1c50bc55d..ab81c5a37 100644 --- a/tests/model/test_saved_settings.py +++ b/tests/model/test_saved_settings.py @@ -14,7 +14,7 @@ from agent_comms.field_codec import FieldCodec from python_introspect import AnnotatedDataclassValidationMixin from toad.acp.status import ToolCallStatus -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import Multiline from toad.preferences import SETTINGS, SavedSettings from toad.setting_choices import BothExpansion, Expansion, FailExpansion from toad.setting_declarations import FamilyChoices diff --git a/tests/textual/test_application_actions_in_the_running_app.py b/tests/textual/test_application_actions_in_the_running_app.py index 76870670c..023c48683 100644 --- a/tests/textual/test_application_actions_in_the_running_app.py +++ b/tests/textual/test_application_actions_in_the_running_app.py @@ -51,9 +51,9 @@ async def open_column_width_editor(ui, app): await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="the settings screen") assert await ui.textual[1].click(app.screen.search) await ui.type('Width of the column') - await ui.until(lambda: app.screen.form.values["ui.column_width"].editor.is_mounted, + await ui.until(lambda: app.screen.values["ui.column_width"].editor.is_mounted, why="the column width editor") - width = app.screen.form.values["ui.column_width"].editor + width = app.screen.values["ui.column_width"].editor width.scroll_visible(animate=False, immediate=True) await ui.step() assert await ui.textual[1].click(width) @@ -196,9 +196,9 @@ def positioned(fraction): await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="settings") assert await ui.textual[1].click(app.screen.search) await ui.type('Column alignment') - await ui.until(lambda: app.screen.form.values["ui.column_alignment"].editor.is_mounted, + await ui.until(lambda: app.screen.values["ui.column_alignment"].editor.is_mounted, why="alignment selector") - selector = app.screen.form.values["ui.column_alignment"].editor + selector = app.screen.values["ui.column_alignment"].editor selector.scroll_visible(animate=False, immediate=True) await ui.step() selector.focus() diff --git a/tests/textual/test_forms.py b/tests/textual/test_forms.py index 6638a6d52..74daa7ff2 100644 --- a/tests/textual/test_forms.py +++ b/tests/textual/test_forms.py @@ -15,9 +15,9 @@ import toad.preferences # noqa: F401 names the process-wide root ObjectState asks for (ObjectState issue #44) from toad.forms import editors -from toad.forms.field_path import FieldPath +from formbridge.forms.declared_fields import FieldPath from toad.forms.form_view import FormView, GroupView, ValueView -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import Multiline from toad.textual_colours import ThemedApp @@ -60,7 +60,7 @@ def __init__(self) -> None: self.state = ObjectState(Everything()) def compose(self) -> ComposeResult: - self.form = FormView(self.state) + self.form = FormView.whole(self.state) yield self.form diff --git a/tests/textual/test_quit_with_unsaved_settings.py b/tests/textual/test_quit_with_unsaved_settings.py index 4da8de899..6ce0a0030 100644 --- a/tests/textual/test_quit_with_unsaved_settings.py +++ b/tests/textual/test_quit_with_unsaved_settings.py @@ -31,9 +31,9 @@ async def test_quit_that_cannot_save_stays_open_and_a_retry_saves_the_edit_and_e await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="the settings screen") assert await pilot.click(app.screen.search) await ui.type('Width of the column') - await ui.until(lambda: app.screen.form.values["ui.column_width"].editor.is_mounted, + await ui.until(lambda: app.screen.values["ui.column_width"].editor.is_mounted, why="the column width editor") - editor = app.screen.form.values["ui.column_width"].editor + editor = app.screen.values["ui.column_width"].editor editor.scroll_visible(animate=False, immediate=True) await ui.step() assert await pilot.click(editor) diff --git a/tests/textual/test_settings_screen.py b/tests/textual/test_settings_screen.py index c4e8482d5..d8cd0595f 100644 --- a/tests/textual/test_settings_screen.py +++ b/tests/textual/test_settings_screen.py @@ -13,10 +13,10 @@ from ui_theme.scheme import PrimaryColour from toad.forms import editors -from toad.forms.declared_views import DeclaredField -from toad.forms.field_path import FieldPath +from formbridge.forms.declared_fields import DeclaredField +from formbridge.forms.declared_fields import FieldPath from toad.forms.form_view import ValueView -from toad.preferences import SETTINGS, SavedSettings, ToadSettings +from toad.preferences import INTERFACE, SETTINGS, SavedSettings, ToadSettings from toad.screens.settings import SchemeEditor, SettingsScreen from toad.setting_effects import Effect from toad.widgets.footer import ToadFooter @@ -49,14 +49,14 @@ class ExperimentSettings(AnnotatedDataclassValidationMixin): @dataclass(frozen=True) class ExtendedSettings(ToadSettings): - experiment: Annotated[ExperimentSettings, FieldLabel("Experiment")] = field(default_factory=ExperimentSettings) + experiment: Annotated[ExperimentSettings, FieldLabel("Experiment"), INTERFACE] = field(default_factory=ExperimentSettings) EXTENDED = FieldPath(ExtendedSettings) async def form_shown(ui, app): - await ui.until(lambda: all(view.is_mounted for view in app.screen.form.values.values()), + await ui.until(lambda: all(view.is_mounted for view in app.screen.values.values()), seconds=5, why="an editor per setting") @@ -79,11 +79,11 @@ async def settings_screen(workspace): def editor(app, path): - return app.screen.form.values[str(path)].editor + return app.screen.values[str(path)].editor def marked(app, path): - return ValueView.CHANGED_MARK in str(app.screen.form.values[str(path)].title.render()) + return ValueView.CHANGED_MARK in str(app.screen.values[str(path)].title.render()) async def edit_text(ui, widget, value): @@ -101,7 +101,7 @@ async def test_opening_the_editors_changes_no_preference(workspace): async def test_every_setting_the_user_may_edit_has_an_editor(workspace): async with settings_screen(workspace) as (ui, app, _file): - shown = app.screen.form.values + shown = app.screen.values structure = app.saved_settings.state.parameter_structure hidden = set() for name in structure.declarations: diff --git a/uv.lock b/uv.lock index 64edc135b..1a15b071b 100644 --- a/uv.lock +++ b/uv.lock @@ -275,7 +275,7 @@ requires-dist = [ { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, { name = "format-currency", specifier = ">=0.0.10" }, - { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?rev=ebe2fa9173428e2bd7154a0de23698757677c3c2" }, + { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?branch=main" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jeepney", marker = "sys_platform == 'linux'", specifier = "==0.9.0" }, { name = "metaclass-registry", git = "https://github.com/OpenHCSDev/metaclass-registry?branch=main" }, @@ -417,7 +417,7 @@ wheels = [ [[package]] name = "formbridge" version = "0.2.0" -source = { git = "https://github.com/OpenHCSDev/formbridge.git?rev=ebe2fa9173428e2bd7154a0de23698757677c3c2#ebe2fa9173428e2bd7154a0de23698757677c3c2" } +source = { git = "https://github.com/OpenHCSDev/formbridge.git?branch=main#ebe2fa9173428e2bd7154a0de23698757677c3c2" } dependencies = [ { name = "docutils" }, { name = "metaclass-registry" }, From fc33f6d459049e6d89a1b01e15abf51f5dd162e6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:39:03 -0400 Subject: [PATCH 2/7] Import each declared-field name once and drop unused imports in touched files Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/forms/editors.py | 4 +--- src/toad/widgets/session_sort.py | 3 +-- src/toad/widgets/tool_call.py | 1 - tests/textual/test_forms.py | 2 -- tests/textual/test_settings_screen.py | 3 +-- 5 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 63293b4af..ae16f037c 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -18,13 +18,11 @@ from textual.validation import Function from textual.geometry import Spacing 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.widgets._input import InputType -from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, declarations -from formbridge.forms.declared_fields import Multiline +from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, Multiline, declarations from toad.forms.presentation import FormCheckbox, FormInput, FormSelectCurrent, FramedControl from toad.widget_view import WidgetView from toad.tone_paint import UntintedTonePaint diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index 8d4f22210..e27836bf6 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -19,9 +19,8 @@ from toad.widget_actions import Activates from textual.geometry import Offset -from formbridge.forms.declared_fields import FieldPath from toad.widgets.comms_menu import ContextMenu -from formbridge.forms.declared_fields import Placeholder +from formbridge.forms.declared_fields import FieldPath, Placeholder from toad.preferences import SETTINGS from toad.setting_declarations import FamilyChoices diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index 091601e91..b8e483ca3 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -10,7 +10,6 @@ from textual.reactive import var from textual.css.query import NoMatches from textual import containers -from textual.widget import Widget from toad.app import ToadApp from toad.tool_output import ToolOutput, ToolOutputPart diff --git a/tests/textual/test_forms.py b/tests/textual/test_forms.py index 74daa7ff2..4f9bd1be3 100644 --- a/tests/textual/test_forms.py +++ b/tests/textual/test_forms.py @@ -11,11 +11,9 @@ from objectstate import ObjectState from python_introspect import AnnotatedDataclassValidationMixin, AnnotationChoices, FieldLabel from textual.app import App, ComposeResult -from textual.widgets import Checkbox, Select import toad.preferences # noqa: F401 names the process-wide root ObjectState asks for (ObjectState issue #44) from toad.forms import editors -from formbridge.forms.declared_fields import FieldPath from toad.forms.form_view import FormView, GroupView, ValueView from formbridge.forms.declared_fields import Multiline from toad.textual_colours import ThemedApp diff --git a/tests/textual/test_settings_screen.py b/tests/textual/test_settings_screen.py index d8cd0595f..fa42a38f3 100644 --- a/tests/textual/test_settings_screen.py +++ b/tests/textual/test_settings_screen.py @@ -13,8 +13,7 @@ from ui_theme.scheme import PrimaryColour from toad.forms import editors -from formbridge.forms.declared_fields import DeclaredField -from formbridge.forms.declared_fields import FieldPath +from formbridge.forms.declared_fields import DeclaredField, FieldPath from toad.forms.form_view import ValueView from toad.preferences import INTERFACE, SETTINGS, SavedSettings, ToadSettings from toad.screens.settings import SchemeEditor, SettingsScreen From 7e6e579804d620bf316cb8a2ba4fc4fd75a4a899 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:39:54 -0400 Subject: [PATCH 3/7] Lock formbridge main with the declared-field semantics merged Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- uv.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 1a15b071b..9ce41607d 100644 --- a/uv.lock +++ b/uv.lock @@ -417,8 +417,9 @@ wheels = [ [[package]] name = "formbridge" version = "0.2.0" -source = { git = "https://github.com/OpenHCSDev/formbridge.git?branch=main#ebe2fa9173428e2bd7154a0de23698757677c3c2" } +source = { git = "https://github.com/OpenHCSDev/formbridge.git?branch=main#b67c97e5c3ad1eda5c06a464fdc578038d01e3e8" } dependencies = [ + { name = "annotated-types" }, { name = "docutils" }, { name = "metaclass-registry" }, { name = "objectstate" }, From cb064ecffcbc80a77b9e2f0b813b9e365e638b89 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:55:05 -0400 Subject: [PATCH 4/7] Own the prompt's input-setting effects on a capability Prompt composes setting_effects imported Prompt inside two new effect functions because a module-level import would cycle (preferences -> setting_effects, prompt -> preferences). The effects now live on PromptSettingEffects in the leaf setting_widget.py as static Effect metadata; Prompt (and MessagePrompt) compose it, each effect enumerates open widgets of that capability, and InputSettings names them directly. setting_effects.prompt_layout and prompt_text are deleted. Both expands_preview status parameters are annotated with ToolCallStatus. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/preferences.py | 12 +++++++----- src/toad/setting_choices.py | 9 ++++++--- src/toad/setting_effects.py | 16 ---------------- src/toad/setting_widget.py | 32 +++++++++++++++++++++++++++++++- src/toad/widgets/prompt.py | 5 +++-- 5 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/toad/preferences.py b/src/toad/preferences.py index e745dffc1..7dc507178 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -49,6 +49,7 @@ SessionBar, ) from toad.setting_declarations import Effect, FamilyChoices +from toad.setting_widget import PromptSettingEffects from toad.widgets.presentation_window import PresentationBudget if TYPE_CHECKING: @@ -58,27 +59,28 @@ @dataclass(frozen=True) class InputSettings(AnnotatedDataclassValidationMixin): box: Annotated[ - type[InputBox], FieldLabel("Layout"), FamilyChoices(InputBox), effects.prompt_layout, + type[InputBox], FieldLabel("Layout"), FamilyChoices(InputBox), + PromptSettingEffects.apply_input_layout, ] = CompactInputBox """Normal draws a padded border around the prompt. Compact keeps it flush with the conversation. Flush also removes the spacing around the label and the info bar.""" - placeholder: Annotated[str, FieldLabel("Placeholder"), effects.prompt_text] = ( + placeholder: Annotated[str, FieldLabel("Placeholder"), PromptSettingEffects.apply_input_text] = ( "How can I help you today?" ) """Text shown in the empty prompt of an agent session.""" agent_symbol: Annotated[ - str, FieldLabel("Message symbol"), MinLen(1), effects.prompt_text, + str, FieldLabel("Message symbol"), MinLen(1), PromptSettingEffects.apply_input_text, ] = "❯" """Shown before a one-line message to the agent.""" multiline_symbol: Annotated[ - str, FieldLabel("Multi-line message symbol"), MinLen(1), effects.prompt_text, + str, FieldLabel("Multi-line message symbol"), MinLen(1), PromptSettingEffects.apply_input_text, ] = "☰" """Shown before a message of several lines or one holding a code fence.""" shell_symbol: Annotated[ - str, FieldLabel("Shell command symbol"), MinLen(1), effects.prompt_text, + str, FieldLabel("Shell command symbol"), MinLen(1), PromptSettingEffects.apply_input_text, ] = "$" """Shown before a shell command.""" diff --git a/src/toad/setting_choices.py b/src/toad/setting_choices.py index e40e8d3bb..731abbc63 100644 --- a/src/toad/setting_choices.py +++ b/src/toad/setting_choices.py @@ -5,7 +5,7 @@ from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass -from typing import ClassVar, cast +from typing import TYPE_CHECKING, ClassVar, cast from random import choice from threading import Lock from textual.color import Color, TRANSPARENT @@ -19,6 +19,9 @@ from agent_comms.declared_choice import DeclaredChoice, TitledChoice from toad.platform import DesktopService, host +if TYPE_CHECKING: + from toad.acp.status import ToolCallStatus + @dataclass(frozen=True) class SchemeName: @@ -35,14 +38,14 @@ class Expansion(DeclaredChoice, affix="Expansion"): def should_expand(cls, status) -> bool: ... @classmethod - def expands_preview(cls, status, preview_fits: Callable[[], bool]) -> bool: + def expands_preview(cls, status: ToolCallStatus, preview_fits: Callable[[], bool]) -> bool: """A completed call opens to show its patch preview when the preview fits.""" return status.completed and preview_fits() class NeverExpansion(Expansion): @classmethod - def expands_preview(cls, status, preview_fits: Callable[[], bool]) -> bool: + def expands_preview(cls, status: ToolCallStatus, preview_fits: Callable[[], bool]) -> bool: return False @classmethod diff --git a/src/toad/setting_effects.py b/src/toad/setting_effects.py index 22261c26c..fc592bfde 100644 --- a/src/toad/setting_effects.py +++ b/src/toad/setting_effects.py @@ -70,19 +70,3 @@ def sessions_bar(app: ToadApp, field: DeclaredField) -> None: def blink_title(app: ToadApp, field: DeclaredField) -> None: app.terminal_attention.update() - -@Effect -def prompt_layout(app: ToadApp, field: DeclaredField) -> None: - """Prompt parts read their room from the input box setting when laid out.""" - from toad.widgets.prompt import Prompt - for prompt in Effect.open_widgets(app, Prompt): - for part in prompt.walk_children(): - part.refresh(layout=True) - prompt.refresh(layout=True) - - -@Effect -def prompt_text(app: ToadApp, field: DeclaredField) -> None: - from toad.widgets.prompt import Prompt - for prompt in Effect.open_widgets(app, Prompt): - prompt.update_prompt() diff --git a/src/toad/setting_widget.py b/src/toad/setting_widget.py index 478fcc46a..218949dc1 100644 --- a/src/toad/setting_widget.py +++ b/src/toad/setting_widget.py @@ -1,4 +1,15 @@ -"""Visibility belongs to its widget declaration, including the first mount.""" +"""Setting capabilities a widget composes: visibility, and the prompt's input settings.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from toad.setting_declarations import Effect + +if TYPE_CHECKING: + from formbridge.forms.declared_fields import DeclaredField + from toad.app import ToadApp + + class SettingVisibility: def __init_subclass__(cls, *, setting: str, **kwargs): super().__init_subclass__(**kwargs) @@ -6,3 +17,22 @@ def __init_subclass__(cls, *, setting: str, **kwargs): def on_mount(self): self.display = self.app.saved_settings.state.get_resolved_value(self.visibility_setting) + + +class PromptSettingEffects: + """A prompt follows the input box settings: these are their effects on every open prompt.""" + + @staticmethod + @Effect + def apply_input_layout(app: ToadApp, field: DeclaredField) -> None: + """Prompt parts read their room from the input box setting when laid out.""" + for prompt in Effect.open_widgets(app, PromptSettingEffects): + for part in prompt.walk_children(): + part.refresh(layout=True) + prompt.refresh(layout=True) + + @staticmethod + @Effect + def apply_input_text(app: ToadApp, field: DeclaredField) -> None: + for prompt in Effect.open_widgets(app, PromptSettingEffects): + prompt.update_prompt() diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 3e7a4574a..92973a969 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -46,7 +46,7 @@ PreviousHistoryCursor, PromptCursor, ) -from toad.setting_widget import SettingVisibility +from toad.setting_widget import PromptSettingEffects, SettingVisibility from toad.slash_command import SlashCommand from toad.widget_actions import ( DismissibleControl, HiddenActions, LocalMethodKeys, NativeMethodKeys, @@ -507,7 +507,8 @@ def can_enter_shell(self) -> bool: return False -class Prompt(NativeMethodKeys, HiddenActions, DismissibleControl, TonePaint, containers.VerticalGroup): +class Prompt(NativeMethodKeys, HiddenActions, DismissibleControl, PromptSettingEffects, TonePaint, + containers.VerticalGroup): tone = PromptToneColour DEFAULT_CSS = """ From ad574c00a354dfca68b7f8c57bde367173f06063 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 05:50:29 -0400 Subject: [PATCH 5/7] Assert field refusals on the field's label and class-hidden settings as hidden test_forms.py:131 still read app notifications after 961e7ca7b moved refusals onto the field's own FieldRefusal; test_settings_screen.py:113 counted ui.keymap.bindings as editable though Keymap is marked hidden as a class. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/textual/test_forms.py | 3 ++- tests/textual/test_settings_screen.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/textual/test_forms.py b/tests/textual/test_forms.py index 4f9bd1be3..a53950ff4 100644 --- a/tests/textual/test_forms.py +++ b/tests/textual/test_forms.py @@ -128,7 +128,8 @@ async def test_a_value_the_declaration_refuses_is_reported_and_the_editor_shows_ async with open_form() as (app, pilot, shown): await leave(pilot, shown["count"], "0") assert app.state.to_object().count == 3 and shown["count"].value == "3" - assert any("at least 1" in notification.message for notification in app._notifications) + refusal = app.form.values["count"].refusal + assert refusal.display and "at least 1" in str(refusal.render()) await leave(pilot, shown["count"], "") assert app.state.to_object().count == 3 and shown["count"].value == "3" diff --git a/tests/textual/test_settings_screen.py b/tests/textual/test_settings_screen.py index fa42a38f3..b3d811e7f 100644 --- a/tests/textual/test_settings_screen.py +++ b/tests/textual/test_settings_screen.py @@ -9,6 +9,7 @@ from python_introspect import AnnotatedDataclassValidationMixin, FieldLabel from ui_theme.catalog import SCHEMES +from objectstate import is_ui_hidden_config_type from ui_theme.colour import Rgb from ui_theme.scheme import PrimaryColour @@ -107,7 +108,7 @@ async def test_every_setting_the_user_may_edit_has_an_editor(workspace): declared = DeclaredField(app.saved_settings.state, structure.path(name)) definition = next(item for item in fields(declared.owner_type) if item.name == declared.path.field_name) - if definition.metadata.get("ui_hidden", False): + if definition.metadata.get("ui_hidden", False) or is_ui_hidden_config_type(declared.annotation): hidden.add(name) hidden.update(path.value for path in structure.leaf_paths(name)) assert set(shown) == {path.value for path in structure.leaf_paths()} - hidden From 82162391b09919d45ec946ca1aad959ce4d63d93 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 05:58:35 -0400 Subject: [PATCH 6/7] Clear the debt the settings menu added: sorted imports, runtime imports where no cycle ToolCallStatus and DeclaredField import at run time; only ToadApp stays behind TYPE_CHECKING in setting_widget.py, since toad.app imports it back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/toad/keymap.py | 15 +++++++-------- src/toad/setting_choices.py | 13 ++++++------- src/toad/setting_declarations.py | 3 ++- src/toad/setting_widget.py | 3 ++- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/toad/keymap.py b/src/toad/keymap.py index 5e7838ded..2f407cb6d 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -1,34 +1,33 @@ """Stored overrides and views of the keyboard declarations.""" from __future__ import annotations -from dataclasses import dataclass, replace +import re from collections.abc import Mapping +from dataclasses import dataclass, replace from functools import cache from importlib import import_module from pkgutil import walk_packages -import re from types import MappingProxyType -from typing import Annotated, TYPE_CHECKING - -import toad -from objectstate import mark_ui_hidden_config +from typing import TYPE_CHECKING, Annotated from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import NominalSpelling, NominalText +from objectstate import mark_ui_hidden_config from textual.binding import Binding, BindingsMap from textual.dom import DOMNode from textual.keys import KeyInvocation from textual.screen import ModalScreen +import toad from toad.key_modes import KeyMode from toad.setting_declarations import Effect, FamilyChoices from toad.widget_actions import ApplicationAction - if TYPE_CHECKING: - from toad.app import ToadApp from formbridge.forms.declared_fields import DeclaredField + from toad.app import ToadApp + @cache def discover() -> None: diff --git a/src/toad/setting_choices.py b/src/toad/setting_choices.py index 731abbc63..163747e59 100644 --- a/src/toad/setting_choices.py +++ b/src/toad/setting_choices.py @@ -5,22 +5,21 @@ from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar, cast from random import choice from threading import Lock -from textual.color import Color, TRANSPARENT +from typing import ClassVar, cast + +from agent_comms.declared_choice import DeclaredChoice, TitledChoice +from textual.color import TRANSPARENT, Color from textual.content import Content from textual.css._style_properties import Edges from textual.css.types import AlignHorizontal from textual.geometry import Spacing from textual.widget import Widget -from toad.widgets.future_text import FutureText -from agent_comms.declared_choice import DeclaredChoice, TitledChoice +from toad.acp.status import ToolCallStatus from toad.platform import DesktopService, host - -if TYPE_CHECKING: - from toad.acp.status import ToolCallStatus +from toad.widgets.future_text import FutureText @dataclass(frozen=True) diff --git a/src/toad/setting_declarations.py b/src/toad/setting_declarations.py index 67371e2aa..244803a41 100644 --- a/src/toad/setting_declarations.py +++ b/src/toad/setting_declarations.py @@ -10,9 +10,10 @@ from python_introspect import AnnotationChoices if TYPE_CHECKING: - from toad.app import ToadApp from formbridge.forms.declared_fields import DeclaredField + from toad.app import ToadApp + @dataclass(frozen=True) class FamilyChoices[ChoiceT: DeclaredChoice](AnnotationChoices): diff --git a/src/toad/setting_widget.py b/src/toad/setting_widget.py index 218949dc1..65e3d74f2 100644 --- a/src/toad/setting_widget.py +++ b/src/toad/setting_widget.py @@ -3,10 +3,11 @@ from typing import TYPE_CHECKING +from formbridge.forms.declared_fields import DeclaredField + from toad.setting_declarations import Effect if TYPE_CHECKING: - from formbridge.forms.declared_fields import DeclaredField from toad.app import ToadApp From e4b889e67b3d07a56fe07682e3c573ea54ace56d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 06:49:29 -0400 Subject: [PATCH 7/7] Let a widget capability declare its setting effects on its own methods EachOpenWidget (setting_declarations.py) owns running a method on every open widget of the class it is declared on, so setting_widget.py no longer names the app type and its TYPE_CHECKING import of toad.app is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/toad/setting_declarations.py | 21 +++++++++++++++++++++ src/toad/setting_widget.py | 31 ++++++++++--------------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/toad/setting_declarations.py b/src/toad/setting_declarations.py index 244803a41..34fde6383 100644 --- a/src/toad/setting_declarations.py +++ b/src/toad/setting_declarations.py @@ -36,3 +36,24 @@ class Effect: def open_widgets(app, widget_type): roots = (*app.workspace_sessions.views.values(), *app.screen_stack) return {widget for root in roots for widget in root.walk_children(widget_type)} + + +class EachOpenWidget(Effect): + """A widget capability's own method as a setting effect. + + The class the method is declared on is the widget type: a change runs the + method on every open widget composing that capability. + """ + + owner: type + + def __init__(self, method: Callable[[object], None]) -> None: + super().__init__(self.on_each_open) + self.method = method + + def __set_name__(self, owner: type, name: str) -> None: + self.owner = owner + + def on_each_open(self, app: ToadApp, field: DeclaredField) -> None: + for widget in self.open_widgets(app, self.owner): + self.method(widget) diff --git a/src/toad/setting_widget.py b/src/toad/setting_widget.py index 65e3d74f2..129b80c8c 100644 --- a/src/toad/setting_widget.py +++ b/src/toad/setting_widget.py @@ -1,14 +1,7 @@ """Setting capabilities a widget composes: visibility, and the prompt's input settings.""" from __future__ import annotations -from typing import TYPE_CHECKING - -from formbridge.forms.declared_fields import DeclaredField - -from toad.setting_declarations import Effect - -if TYPE_CHECKING: - from toad.app import ToadApp +from toad.setting_declarations import EachOpenWidget class SettingVisibility: @@ -23,17 +16,13 @@ def on_mount(self): class PromptSettingEffects: """A prompt follows the input box settings: these are their effects on every open prompt.""" - @staticmethod - @Effect - def apply_input_layout(app: ToadApp, field: DeclaredField) -> None: + @EachOpenWidget + def apply_input_layout(self) -> None: """Prompt parts read their room from the input box setting when laid out.""" - for prompt in Effect.open_widgets(app, PromptSettingEffects): - for part in prompt.walk_children(): - part.refresh(layout=True) - prompt.refresh(layout=True) - - @staticmethod - @Effect - def apply_input_text(app: ToadApp, field: DeclaredField) -> None: - for prompt in Effect.open_widgets(app, PromptSettingEffects): - prompt.update_prompt() + for part in self.walk_children(): + part.refresh(layout=True) + self.refresh(layout=True) + + @EachOpenWidget + def apply_input_text(self) -> None: + self.update_prompt()