From e5cee4745ccc8fe279f0cebe99515fefe1af3fa2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 13 Nov 2026 01:47:57 -0500 Subject: [PATCH 1/2] Restore viewed-thread panels and edit inherited settings through original owners --- .verification/viewed-object-repair/author.py | 68 +++++ .../viewed-object-repair/consumer.plan.json | 64 ++++ .../cycle-import.plan.json | 16 + .../viewed-object-repair/cycle.plan.json | 58 ++++ .../viewed-object-repair/cycle_author.py | 15 + .../viewed-object-repair/discovery.plan.json | 175 +++++++++++ .../viewed-object-repair/discovery_author.py | 101 +++++++ .../viewed-object-repair/form-base.plan.json | 35 +++ .../viewed-object-repair/forms.plan.json | 120 ++++++++ .../viewed-object-repair/forms_author.py | 94 ++++++ .verification/viewed-object-repair/order.json | 53 ++++ .../viewed-object-repair/panels.plan.json | 279 ++++++++++++++++++ .../viewed-object-repair/panels_author.py | 96 ++++++ .../viewed-object-repair/peer.plan.json | 58 ++++ .../viewed-object-repair/peer_author.py | 30 ++ .../viewed-object-repair/preview.plan.json | 164 ++++++++++ .../viewed-object-repair/test-label.plan.json | 20 ++ src/toad/configured_view.py | 23 +- src/toad/conversation_kind.py | 6 + src/toad/forms/editors.py | 33 ++- src/toad/forms/form_view.py | 2 +- src/toad/forms/object_configuration.py | 19 +- src/toad/navigation_target.py | 19 +- src/toad/object_configuration.py | 7 + src/toad/screens/comms.py | 4 + src/toad/screens/session_view.py | 14 +- src/toad/session_scope.py | 8 +- src/toad/settings_scope.py | 4 + src/toad/thread_actions.py | 26 +- src/toad/thread_identity.py | 9 + src/toad/widgets/comms_command_dialog.py | 56 +++- src/toad/widgets/context_explorer.py | 8 +- src/toad/widgets/session_thread_panels.py | 8 +- src/toad/widgets/session_thread_sidebar.py | 52 +++- src/toad/widgets/side_bar.py | 23 +- src/toad/widgets/thread_comms.py | 20 +- src/toad/widgets/thread_identity.py | 10 +- tests/textual/test_sidebar_projection.py | 53 +++- 38 files changed, 1771 insertions(+), 79 deletions(-) create mode 100644 .verification/viewed-object-repair/author.py create mode 100644 .verification/viewed-object-repair/consumer.plan.json create mode 100644 .verification/viewed-object-repair/cycle-import.plan.json create mode 100644 .verification/viewed-object-repair/cycle.plan.json create mode 100644 .verification/viewed-object-repair/cycle_author.py create mode 100644 .verification/viewed-object-repair/discovery.plan.json create mode 100644 .verification/viewed-object-repair/discovery_author.py create mode 100644 .verification/viewed-object-repair/form-base.plan.json create mode 100644 .verification/viewed-object-repair/forms.plan.json create mode 100644 .verification/viewed-object-repair/forms_author.py create mode 100644 .verification/viewed-object-repair/order.json create mode 100644 .verification/viewed-object-repair/panels.plan.json create mode 100644 .verification/viewed-object-repair/panels_author.py create mode 100644 .verification/viewed-object-repair/peer.plan.json create mode 100644 .verification/viewed-object-repair/peer_author.py create mode 100644 .verification/viewed-object-repair/preview.plan.json create mode 100644 .verification/viewed-object-repair/test-label.plan.json diff --git a/.verification/viewed-object-repair/author.py b/.verification/viewed-object-repair/author.py new file mode 100644 index 000000000..150e8b736 --- /dev/null +++ b/.verification/viewed-object-repair/author.py @@ -0,0 +1,68 @@ +import json +from pathlib import Path +root=Path.cwd(); ops=[] +def patch(f,t,o,n): ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def insert(f,t,s): ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +def imp(f,s): ops.append(dict(operation='ensure_import',file_path=str(root/f),import_source=s)) +f='src/toad/forms/editors.py' +imp(f,'from ui_theme.scheme import MutedColour') +insert(f,'ValueEditor.control_mounted',''' @property + def previewing(self) -> bool: + """The raw field inherits; its resolved value is only a preview.""" + return self.field.state.parameters[self.field.path.value] is None and self.field.value is not None + + def paint_value(self) -> None: + self.paint() + self.styles.color = self.app.colours.colour(MutedColour) if self.previewing else None + self.styles.text_style = "dim" if self.previewing else "" + +''') +patch(f,'ValueEditor.control_mounted','self.call_after_refresh(self.paint)','self.call_after_refresh(self.paint_value)') +insert(f,'LineEditor.control_arguments',''' def current_text(self) -> str: + return "" if self.previewing else self.value_text(self.field.value) + +''') +patch(f,'LineEditor.control_arguments',''' "value": self.value_text(self.field.value),''',''' "value": self.current_text(), + "placeholder": self.value_text(self.field.value) if self.previewing else self.field.placeholder,''') +patch(f,'LineEditor.finish','self.value == self.value_text(self.field.value)','self.value == self.current_text()') +patch(f,'LineEditor.paint',''' self.value = self.value_text(self.field.value)''',''' self.placeholder = self.value_text(self.field.value) if self.previewing else self.field.placeholder + self.value = self.current_text()''') +patch(f,'SelectEditor.control_arguments',''' listed = any(value == choice for _, choice in options)''',''' listed = not self.previewing and any(value == choice for _, choice in options)''') +patch(f,'SelectEditor.control_arguments',''' "prompt": self.field.placeholder if value is None else str(value),''',''' "prompt": next((label for label, choice in options if choice == value), + self.field.placeholder if value is None else str(value)),''') +patch(f,'SelectEditor.paint',''' self.value = value if self.lists(value) else Select.NULL''',''' self.value = value if not self.previewing and self.lists(value) else Select.NULL''') +f='src/toad/forms/form_view.py' +patch(f,'FormView.values_changed','editor.paint()','editor.paint_value()') +f='src/toad/object_configuration.py' +insert(f,'ObjectSettingField.edit',''' def reset(self) -> None: + """The writer resets its actual parameter to that parameter's signature default.""" + state = self.state if self.allows_override else self.state.defaults + state.reset_parameter(self.path.value) + if state is self.state.defaults: + self.state.global_settings.save_sync() + +''') +f='src/toad/forms/object_configuration.py' +patch(f,'InheritSettingButton', '''class InheritSettingButton(ActingButton): + def __init__(self, field): + super().__init__("Override here") + self.field = field + + async def act(self): + if self.field.state.parameters[self.field.path.value] is None: + self.field.edit(self.field.value) + else: + self.field.state.reset_parameter(self.field.path.value)''','''class ResetSettingButton(ActingButton): + """Reset uses the parameter's own signature, including lazy None defaults.""" + def __init__(self, field): + super().__init__("Reset") + self.field = field + + async def act(self): + self.field.reset()''') +patch(f,'ObjectValueView.__init__','self.inherit = InheritSettingButton(field)','self.reset = ResetSettingButton(field)') +patch(f,'ObjectValueView.compose','yield self.inherit','yield self.reset') +patch(f,'ObjectValueView.mark', ''' self.inherit.display = local + self.inherit.label = "Use global default" if explicit else "Override here"''',''' if self.editor.is_mounted: + self.editor.paint_value()''') +Path('.verification/viewed-object-repair/preview.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'paint-inherited-previews-and-reset-the-original-parameter','operations':ops}]},indent=2)) diff --git a/.verification/viewed-object-repair/consumer.plan.json b/.verification/viewed-object-repair/consumer.plan.json new file mode 100644 index 000000000..aff407a69 --- /dev/null +++ b/.verification/viewed-object-repair/consumer.plan.json @@ -0,0 +1,64 @@ +{ + "recipes": [ + { + "recipe_id": "exercise-the-original-view-menu-and-original-writers", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " assert not field_view.inherit.display", + "new_source": " assert field_view.reset.label.plain == 'Reset' " + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " assert state.fields.ui.column_width == 83\n state.field('ui.column_width').edit(91)", + "new_source": " assert state.fields.ui.column_width == 83\n width = next(part for part in menu.query(ObjectValueView)\n if part.field.path.value == 'ui.column_width')\n await ui.until(lambda: width.editor.placeholder == '83', why='inherited width preview')\n assert width.editor.value == '' and width.editor.previewing\n width.editor.value = '83'\n width.editor.finish()\n assert state.parameters['ui.column_width'] == 83\n assert not width.editor.previewing\n state.field('ui.column_width').edit(91)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " state.reset_parameter('ui.column_width')\n assert state.fields.ui.column_width == 85", + "new_source": " await width.reset.act()\n assert state.parameters['ui.column_width'] is None\n assert state.fields.ui.column_width == 85\n await ui.until(lambda: width.editor.placeholder == '85', why='reset restores lazy preview')\n assert width.editor.value == '' and width.editor.previewing" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " workspace.thread('panel-owner')\n await workspace.bind(native, 'panel-owner')", + "new_source": " peer = workspace.thread('panel-owner')\n from agent_comms.team.pi_rule_gate import PiRedeal\n deal = PiRedeal.of(workspace.comms, peer)\n deal.start()\n await workspace.bind(native, 'panel-owner')" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " relationships.widget._visible()\n settings_entry", + "new_source": " relationships.widget._visible()\n from toad.widgets.session_thread_panels import SessionPanel\n assert set(SessionPanel.members_with(SessionPanel)) == {\n type(owner) for owner in native_bar._panel_owners}\n from agent_comms.cli_verbs.configure_rule_gate import ThreadRuleGateCliCommand\n from toad.configured_view import ActionProperties\n await ui.until(lambda: any(isinstance(entry.widget, ActionProperties)\n and entry.widget.definition.declaration is ThreadRuleGateCliCommand\n for entry in native_menu.entries), why='original rule-gate configuration')\n gate_entry = next(entry for entry in native_menu.entries\n if isinstance(entry.widget, ActionProperties)\n and entry.widget.definition.declaration is ThreadRuleGateCliCommand)\n native_menu.picker.value = gate_entry\n await ui.until(lambda: gate_entry.widget.is_mounted and bool(gate_entry.widget.form.values),\n why='original backend rule-gate form')\n from agent_comms.team.rule_deal import RuleGateSettings\n from dataclasses import fields\n assert set(gate_entry.widget.form.values) == {field.name for field in fields(RuleGateSettings)}\n from toad.widgets.comms_command_dialog import CommandPreparation\n await ui.until(lambda: not CommandPreparation.running_on(gate_entry.widget), why='gate choices loaded')\n gate_entry.widget.form.values['read_batch'].editor.value = '7'\n await gate_entry.widget.confirm()\n await ui.until(lambda: deal.current().read_batch == 7, why='original gate writer saves settings')\n dm = await ui.open_direct('panel-owner', actor=workspace.viewer)\n dm_view = app.workspace_sessions.require(dm)\n dm_bar = await ui.revealed(dm_view.thread_sidebar)\n dm_menu = dm_bar.query_one(PanelMenu)\n dm_relationships = next(entry for entry in dm_menu.entries\n if isinstance(entry.widget, ThreadCommsSidebar))\n dm_menu.picker.value = dm_relationships\n await ui.until(lambda: dm_relationships.widget.is_mounted, why='DM original relationship panel')\n assert dm_relationships.widget.thread_identity.thread_incarnation() == peer.incarnation\n assert dm_view.thread_identity.thread_name() == workspace.viewer\n await ui.select_tab(original)\n settings_entry" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/cycle-import.plan.json b/.verification/viewed-object-repair/cycle-import.plan.json new file mode 100644 index 000000000..10ac6605f --- /dev/null +++ b/.verification/viewed-object-repair/cycle-import.plan.json @@ -0,0 +1,16 @@ +{ + "recipes": [ + { + "recipe_id": "remove-presentation-dependency-from-conversation-values", + "operations": [ + { + "operation": "remove_import_bindings", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/conversation_kind.py", + "bound_names": [ + "ThreadIdentity" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/cycle.plan.json b/.verification/viewed-object-repair/cycle.plan.json new file mode 100644 index 000000000..5de1d5140 --- /dev/null +++ b/.verification/viewed-object-repair/cycle.plan.json @@ -0,0 +1,58 @@ +{ + "recipes": [ + { + "recipe_id": "keep-conversation-subjects-below-panel-presentation", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/conversation_kind.py", + "target_qualname": "ConversationKind.sidebar_identities", + "replacements": [ + { + "old_source": "def sidebar_identities(self, root: str):", + "new_source": "def sidebar_threads(self):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/conversation_kind.py", + "target_qualname": "DmConversation.sidebar_identities", + "replacements": [ + { + "old_source": " def sidebar_identities(self, root: str):\n return (ThreadIdentity(self.peer, root),)", + "new_source": " def sidebar_threads(self):\n return (self.peer,)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/comms.py", + "target_qualname": "CommsScreen.make_thread_sidebar", + "replacements": [ + { + "old_source": "self.conversation.sidebar_identities(self.wire_root)", + "new_source": "self.conversation.sidebar_threads()" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "target_qualname": "ConversationThreadSidebar.__init__", + "replacements": [ + { + "old_source": " def __init__(self, screen, identities):\n self.identities = identities", + "new_source": " def __init__(self, screen, threads):\n self.identities = tuple(ThreadIdentity(thread, screen.wire_root) for thread in threads)" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "import_source": "from toad.thread_identity import ThreadIdentity" + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/cycle_author.py b/.verification/viewed-object-repair/cycle_author.py new file mode 100644 index 000000000..4d1cde458 --- /dev/null +++ b/.verification/viewed-object-repair/cycle_author.py @@ -0,0 +1,15 @@ +import json +from pathlib import Path +r=Path.cwd();ops=[] +def p(f,t,a,b):ops.append(dict(operation='patch_target',file_path=str(r/f),target_qualname=t,replacements=[dict(old_source=a,new_source=b)])) +def i(f,s):ops.append(dict(operation='ensure_import',file_path=str(r/f),import_source=s)) +p('src/toad/conversation_kind.py','ConversationKind.sidebar_identities','def sidebar_identities(self, root: str):','def sidebar_threads(self):') +p('src/toad/conversation_kind.py','DmConversation.sidebar_identities',''' def sidebar_identities(self, root: str): + return (ThreadIdentity(self.peer, root),)''',''' def sidebar_threads(self): + return (self.peer,)''') +p('src/toad/screens/comms.py','CommsScreen.make_thread_sidebar','self.conversation.sidebar_identities(self.wire_root)','self.conversation.sidebar_threads()') +p('src/toad/widgets/session_thread_sidebar.py','ConversationThreadSidebar.__init__',''' def __init__(self, screen, identities): + self.identities = identities''',''' def __init__(self, screen, threads): + self.identities = tuple(ThreadIdentity(thread, screen.wire_root) for thread in threads)''') +i('src/toad/widgets/session_thread_sidebar.py','from toad.thread_identity import ThreadIdentity') +Path('.verification/viewed-object-repair/cycle.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'keep-conversation-subjects-below-panel-presentation','operations':ops}]},indent=2)) diff --git a/.verification/viewed-object-repair/discovery.plan.json b/.verification/viewed-object-repair/discovery.plan.json new file mode 100644 index 000000000..1d48818d6 --- /dev/null +++ b/.verification/viewed-object-repair/discovery.plan.json @@ -0,0 +1,175 @@ +{ + "recipes": [ + { + "recipe_id": "discover-configuration-on-the-actual-viewed-target", + "operations": [ + { + "operation": "insert_after_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/settings_scope.py", + "target_qualname": "ViewSettingsScope.configuration_panels", + "source": " async def configuration_action_panels(self):\n \"\"\"Backend configuration is absent unless the viewed object supplies targets.\"\"\"\n return ()\n\n" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "import_source": "from toad.widgets.comms_command_dialog import CommandForm" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "import_source": "from toad.thread_actions import ThreadAction" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ObjectConfigurationDialog", + "source": "class ActionProperties(CommandForm, SidebarPanelWidget):\n \"\"\"The original backend operation form, using its original target writer.\"\"\"\n\n def __init__(self, context, definition, fields):\n self.context = context\n super().__init__(definition, context.title, fields, context.observed.core)\n\n @property\n def panel_identity(self):\n return self.definition.declaration\n\n async def submit(self, arguments):\n await ThreadAction.submit(self.context, self.definition, arguments)\n\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ObjectConfigurationDialog.__init__", + "replacements": [ + { + "old_source": "def __init__(self, panels):", + "new_source": "def __init__(self, view):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ObjectConfigurationDialog.__init__", + "replacements": [ + { + "old_source": "PanelMenu(panels, SidebarState())", + "new_source": "PanelMenu(view.configuration_panels, SidebarState(), view)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/configured_view.py", + "target_qualname": "ConfiguredView.open_configuration", + "replacements": [ + { + "old_source": "ObjectConfigurationDialog(self.configuration_panels)", + "new_source": "ObjectConfigurationDialog(self)" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "NavigationTarget.matches", + "source": " def configuration_contexts(self, view):\n \"\"\"A destination without a configurable subject supplies no command context.\"\"\"\n return ()\n\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "ThreadTarget", + "source": "class ConfigurationTarget:\n \"\"\"Named thread and history destinations borrow Core's configuration membership.\"\"\"\n\n def configuration_contexts(self, view):\n navigation = view.navigation_context\n observed = view.app.coordination_access.observing(view.coordination_root)\n return (TargetContext(view.app, observed, CommandTarget.named(self.name),\n navigation.actor, navigation.project_path,\n navigation.owner_mode),)\n\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "ThreadTarget", + "replacements": [ + { + "old_source": "class ThreadTarget(NavigationTarget):", + "new_source": "class ThreadTarget(ConfigurationTarget, NavigationTarget):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "HistoryTarget", + "replacements": [ + { + "old_source": "class HistoryTarget(HistoryRoute, NavigationTarget):", + "new_source": "class HistoryTarget(ConfigurationTarget, HistoryRoute, NavigationTarget):" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/session_view.py", + "import_source": "from toad.configured_view import ActionProperties" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/session_view.py", + "target_qualname": "SidebarSessionView.paint_background", + "source": " async def configuration_action_panels(self):\n def read():\n entries = []\n for context in self.navigation_target.configuration_contexts(self):\n for action in context.available_actions():\n for configuration in action.declaration.configuration_actions(action):\n entries.append((context, configuration, configuration.editable_fields))\n return entries\n declarations = await self.app.preparation.run_thread(read)\n return tuple(SideBar.Panel(action.label, ActionProperties(context, action, fields), flex=True)\n for context, action, fields in declarations)\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelMenu.__init__", + "replacements": [ + { + "old_source": "def __init__(self, entries, navigation: SidebarState):", + "new_source": "def __init__(self, entries, navigation: SidebarState, scope: ViewSettingsScope):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelMenu.__init__", + "replacements": [ + { + "old_source": "self.navigation = navigation", + "new_source": "self.navigation = navigation\n self.scope = scope" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelMenu.open_selection", + "replacements": [ + { + "old_source": "await self.show_panel(self.picker.value)", + "new_source": "await self.show_panel(self.picker.value)\n self.run_worker(self.load_configuration(), group=\"configuration-panels\", exclusive=True)" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "PanelMenu.selection_changed", + "source": " async def load_configuration(self):\n try:\n entries = await self.scope.configuration_action_panels()\n except CLI_FAILURES as error:\n self.app.show_error(error, \"Object settings\")\n return\n if not self.is_attached:\n return\n selected = self.picker.value\n self.entries += tuple(entries)\n with self.prevent(widgets.Select.Changed):\n self.picker.set_options((entry.title, entry) for entry in self.entries)\n self.picker.value = selected\n\n" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "import_source": "from agent_comms.cli_support.cli_failures import CLI_FAILURES" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "ThreadSidebar.panel_entries", + "replacements": [ + { + "old_source": " yield from self.query_ancestor(ViewSettingsScope).configuration_panels\n yield from super().panel_entries()", + "new_source": " yield from super().panel_entries()\n yield from self.query_ancestor(ViewSettingsScope).configuration_panels" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/side_bar.py", + "target_qualname": "ThreadSidebar._compose_panels", + "replacements": [ + { + "old_source": "PanelMenu(self.panel_entries(), self.navigation)", + "new_source": "PanelMenu(self.panel_entries(), self.navigation, self.query_ancestor(ViewSettingsScope))" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/discovery_author.py b/.verification/viewed-object-repair/discovery_author.py new file mode 100644 index 000000000..e9d6c4df7 --- /dev/null +++ b/.verification/viewed-object-repair/discovery_author.py @@ -0,0 +1,101 @@ +import json +from pathlib import Path +root=Path.cwd();ops=[] +def patch(f,t,o,n):ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def insert(f,t,s):ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +def imp(f,s):ops.append(dict(operation='ensure_import',file_path=str(root/f),import_source=s)) +f='src/toad/settings_scope.py' +insert(f,'ConversationSettingsScope',''' async def configuration_action_panels(self): + """Backend configuration is absent unless the viewed object supplies targets.""" + return () + +''') +# insertion before a class is module scope; insert method within the owning class instead. +ops[-1]['target_qualname']='ViewSettingsScope.configuration_panels' +ops[-1]['operation']='insert_after_target' +f='src/toad/configured_view.py' +imp(f,'from toad.widgets.comms_command_dialog import CommandForm') +imp(f,'from toad.thread_actions import ThreadAction') +insert(f,'ObjectConfigurationDialog','''class ActionProperties(CommandForm, SidebarPanelWidget): + """The original backend operation form, using its original target writer.""" + + def __init__(self, context, definition, fields): + self.context = context + super().__init__(definition, context.title, fields, context.observed.core) + + @property + def panel_identity(self): + return self.definition.declaration + + async def submit(self, arguments): + await ThreadAction.submit(self.context, self.definition, arguments) + + +''') +patch(f,'ObjectConfigurationDialog.__init__','def __init__(self, panels):','def __init__(self, view):') +patch(f,'ObjectConfigurationDialog.__init__','PanelMenu(panels, SidebarState())','PanelMenu(view.configuration_panels, SidebarState(), view)') +patch(f,'ConfiguredView.open_configuration','ObjectConfigurationDialog(self.configuration_panels)','ObjectConfigurationDialog(self)') +f='src/toad/navigation_target.py' +insert(f,'NavigationTarget.matches',''' def configuration_contexts(self, view): + """A destination without a configurable subject supplies no command context.""" + return () + +''') +insert(f,'ThreadTarget','''class ConfigurationTarget: + """Named thread and history destinations borrow Core's configuration membership.""" + + def configuration_contexts(self, view): + navigation = view.navigation_context + observed = view.app.coordination_access.observing(view.coordination_root) + return (TargetContext(view.app, observed, CommandTarget.named(self.name), + navigation.actor, navigation.project_path, + navigation.owner_mode),) + + +''') +patch(f,'ThreadTarget','class ThreadTarget(NavigationTarget):','class ThreadTarget(ConfigurationTarget, NavigationTarget):') +patch(f,'HistoryTarget','class HistoryTarget(HistoryRoute, NavigationTarget):','class HistoryTarget(ConfigurationTarget, HistoryRoute, NavigationTarget):') +f='src/toad/screens/session_view.py' +imp(f,'from toad.configured_view import ActionProperties') +insert(f,'SidebarSessionView.paint_background',''' async def configuration_action_panels(self): + def read(): + entries = [] + for context in self.navigation_target.configuration_contexts(self): + for action in context.available_actions(): + for configuration in action.declaration.configuration_actions(action): + entries.append((context, configuration, configuration.editable_fields)) + return entries + declarations = await self.app.preparation.run_thread(read) + return tuple(SideBar.Panel(action.label, ActionProperties(context, action, fields), flex=True) + for context, action, fields in declarations) + +''') +# The paint_background property decorator must stay on its original method. +# insert_before_target inserts before decorators, leaving the added method undecorated. +f='src/toad/widgets/side_bar.py' +patch(f,'PanelMenu.__init__','def __init__(self, entries, navigation: SidebarState):','def __init__(self, entries, navigation: SidebarState, scope: ViewSettingsScope):') +patch(f,'PanelMenu.__init__','self.navigation = navigation','self.navigation = navigation\n self.scope = scope') +patch(f,'PanelMenu.open_selection','await self.show_panel(self.picker.value)','await self.show_panel(self.picker.value)\n self.run_worker(self.load_configuration(), group="configuration-panels", exclusive=True)') +insert(f,'PanelMenu.selection_changed',''' async def load_configuration(self): + try: + entries = await self.scope.configuration_action_panels() + except Exception as error: + self.app.show_error(error, "Object settings") + return + if not self.is_attached: + return + selected = self.picker.value + self.entries += tuple(entries) + with self.prevent(widgets.Select.Changed): + self.picker.set_options((entry.title, entry) for entry in self.entries) + self.picker.value = selected + +''') +# No broad catch: use the original backend error family only. +ops[-1]['source']=ops[-1]['source'].replace('except Exception as error:', 'except CLI_FAILURES as error:') +imp(f,'from agent_comms.cli_support.cli_failures import CLI_FAILURES') +patch(f,'ThreadSidebar.panel_entries',''' yield from self.query_ancestor(ViewSettingsScope).configuration_panels + yield from super().panel_entries()''',''' yield from super().panel_entries() + yield from self.query_ancestor(ViewSettingsScope).configuration_panels''') +patch(f,'ThreadSidebar._compose_panels','PanelMenu(self.panel_entries(), self.navigation)','PanelMenu(self.panel_entries(), self.navigation, self.query_ancestor(ViewSettingsScope))') +Path('.verification/viewed-object-repair/discovery.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'discover-configuration-on-the-actual-viewed-target','operations':ops}]},indent=2)) diff --git a/.verification/viewed-object-repair/form-base.plan.json b/.verification/viewed-object-repair/form-base.plan.json new file mode 100644 index 000000000..5f67c8b29 --- /dev/null +++ b/.verification/viewed-object-repair/form-base.plan.json @@ -0,0 +1,35 @@ +{ + "recipes": [ + { + "recipe_id": "name-the-shared-backend-form", + "operations": [ + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "import_source": "from abc import abstractmethod" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "import_source": "from textual.containers import Vertical" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "import_source": "from toad.widget_actions import DismissibleControl" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "CommandDialog", + "replacements": [ + { + "old_source": "class CommandDialog(Confirms, ShadedScreen, CenteredScreen, DismissibleScreen[dict[str, str | None]], actions=(ApplyCommandAction,)):", + "new_source": "class CommandForm(Confirms, DismissibleControl, Vertical, actions=(ApplyCommandAction,)):\n \"\"\"One backend operation, embedded in a panel or presented as a dialog.\"\"\"" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/forms.plan.json b/.verification/viewed-object-repair/forms.plan.json new file mode 100644 index 000000000..f9ae54450 --- /dev/null +++ b/.verification/viewed-object-repair/forms.plan.json @@ -0,0 +1,120 @@ +{ + "recipes": [ + { + "recipe_id": "share-the-original-command-editor-and-writer", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "CommandForm.compose", + "replacements": [ + { + "old_source": " with CommandBox():\n yield CommandTitle(f\"{self.definition.label} \u2014 {self.target}\", markup=False)\n with CommandFormScroll():\n yield CommandConfirmation('', markup=False)\n yield self.form\n yield CommandConfirmed('I confirm this operation')\n with CommandButtons():\n yield ApplyCommandButton('Apply', variant='primary')\n yield CancelCommandButton('Cancel')", + "new_source": " yield CommandTitle(f\"{self.definition.label} \u2014 {self.target}\", markup=False)\n with CommandFormScroll():\n yield CommandConfirmation('', markup=False)\n yield self.form\n yield CommandConfirmed('I confirm this operation')\n with CommandButtons():\n yield ApplyCommandButton('Apply', variant='primary')\n yield CancelCommandButton('Cancel')" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "CommandForm.confirm", + "replacements": [ + { + "old_source": "self.dismiss(arguments)", + "new_source": "await self.submit(arguments)" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "CommandForm.confirm", + "source": " @abstractmethod\n async def submit(self, arguments: dict[str, str | None]) -> None: ...\n\n def finish(self) -> None:\n self.form.finish()\n\n def dismiss_control(self) -> None:\n \"\"\"An embedded form discards its draft without closing the containing view.\"\"\"\n for path in self.state.parameters:\n self.state.reset_parameter(path)\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "CatalogFieldSelection.connect_provider", + "replacements": [ + { + "old_source": "self.editor.query_ancestor(CommandDialog)", + "new_source": "self.editor.query_ancestor(CommandForm)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "ApplyCommandAction.apply", + "replacements": [ + { + "old_source": "context: CommandDialog", + "new_source": "context: CommandForm" + } + ] + }, + { + "operation": "insert_after_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "CommandForm", + "source": "class CommandDialog(CommandForm, ShadedScreen, CenteredScreen, DismissibleScreen[dict[str, str | None]]):\n \"\"\"The shared operation form in its modal presentation.\"\"\"\n\n def compose(self) -> ComposeResult:\n with CommandBox():\n yield from super().compose()\n\n async def submit(self, arguments: dict[str, str | None]) -> None:\n self.dismiss(arguments)\n\n def dismiss_control(self) -> None:\n self.dismiss(None)\n" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_actions.py", + "import_source": "from toad.widgets.comms_command_dialog import CommandDialog" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_actions.py", + "target_qualname": "ThreadAction.collect", + "replacements": [ + { + "old_source": " from toad.widgets.comms_command_dialog import CommandDialog\n\n", + "new_source": "" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_actions.py", + "target_qualname": "ThreadAction.collect", + "replacements": [ + { + "old_source": " def prepare():\n ctx.current()\n return TargetEdit(declaration=definition.declaration,\n target=definition.declaration.selected_targets(ctx.selection),\n arguments=arguments, confirmed=bool(definition.edited(arguments).confirmation),\n channel=definition.channels, project=ctx.project)\n request = await ctx.app.preparation.run_thread(prepare)\n ctx.app.thread_actions.invoke(cls(definition, request, ctx), ctx.title,\n source_root=ctx.observed.root)", + "new_source": " await cls.submit(ctx, definition, arguments)" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_actions.py", + "target_qualname": "ThreadAction.collect", + "source": " @classmethod\n async def submit(cls, ctx, definition, arguments):\n \"\"\"Both presentations execute through the original guarded target writer.\"\"\"\n def prepare():\n ctx.current()\n return TargetEdit(declaration=definition.declaration,\n target=definition.declaration.selected_targets(ctx.selection),\n arguments=arguments, confirmed=bool(definition.edited(arguments).confirmation),\n channel=definition.channels, project=ctx.project)\n request = await ctx.app.preparation.run_thread(prepare)\n ctx.app.thread_actions.invoke(cls(definition, request, ctx), ctx.title,\n source_root=ctx.observed.root)\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "SelectEditor.control_arguments", + "replacements": [ + { + "old_source": "\"allow_blank\": not listed", + "new_source": "\"allow_blank\": True" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/object_configuration.py", + "target_qualname": "ResetSettingButton.act", + "replacements": [ + { + "old_source": "self.field.reset()", + "new_source": "self.field.reset()\n self.query_ancestor(ObjectValueView).editor.feedback = \"\"" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/forms_author.py b/.verification/viewed-object-repair/forms_author.py new file mode 100644 index 000000000..e936cc456 --- /dev/null +++ b/.verification/viewed-object-repair/forms_author.py @@ -0,0 +1,94 @@ +import json +from pathlib import Path +root=Path.cwd();ops=[] +def patch(f,t,o,n):ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def insert(f,t,s):ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +def imp(f,s):ops.append(dict(operation='ensure_import',file_path=str(root/f),import_source=s)) +f='src/toad/widgets/comms_command_dialog.py' +imp(f,'from abc import abstractmethod') +imp(f,'from textual.containers import Vertical') +imp(f,'from toad.widget_actions import DismissibleControl') +patch(f,'CommandDialog','class CommandDialog(Confirms, ShadedScreen, CenteredScreen, DismissibleScreen[dict[str, str | None]], actions=(ApplyCommandAction,)):','class CommandForm(Confirms, DismissibleControl, Vertical, actions=(ApplyCommandAction,)):\n """One backend operation, embedded in a panel or presented as a dialog."""') +# Replacing the whole declaration first renames its nested targets for the following recipe. +Path('.verification/viewed-object-repair/form-base.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'name-the-shared-backend-form','operations':ops}]},indent=2)) +ops=[] +patch(f,'CommandForm.compose',''' with CommandBox(): + yield CommandTitle(f"{self.definition.label} — {self.target}", markup=False) + with CommandFormScroll(): + yield CommandConfirmation('', markup=False) + yield self.form + yield CommandConfirmed('I confirm this operation') + with CommandButtons(): + yield ApplyCommandButton('Apply', variant='primary') + yield CancelCommandButton('Cancel')''',''' yield CommandTitle(f"{self.definition.label} — {self.target}", markup=False) + with CommandFormScroll(): + yield CommandConfirmation('', markup=False) + yield self.form + yield CommandConfirmed('I confirm this operation') + with CommandButtons(): + yield ApplyCommandButton('Apply', variant='primary') + yield CancelCommandButton('Cancel')''') +patch(f,'CommandForm.confirm','self.dismiss(arguments)','await self.submit(arguments)') +insert(f,'CommandForm.confirm',''' @abstractmethod + async def submit(self, arguments: dict[str, str | None]) -> None: ... + + def finish(self) -> None: + self.form.finish() + + def dismiss_control(self) -> None: + """An embedded form discards its draft without closing the containing view.""" + for path in self.state.parameters: + self.state.reset_parameter(path) + +''') +patch(f,'CatalogFieldSelection.connect_provider','self.editor.query_ancestor(CommandDialog)','self.editor.query_ancestor(CommandForm)') +patch(f,'ApplyCommandAction.apply','context: CommandDialog','context: CommandForm') +# Native insertion after the complete form using a module target append operation in the next plan. +ops.append(dict(operation='insert_after_target',file_path=str(root/f),target_qualname='CommandForm',source='''class CommandDialog(CommandForm, ShadedScreen, CenteredScreen, DismissibleScreen[dict[str, str | None]]): + """The shared operation form in its modal presentation.""" + + def compose(self) -> ComposeResult: + with CommandBox(): + yield from super().compose() + + async def submit(self, arguments: dict[str, str | None]) -> None: + self.dismiss(arguments) + + def dismiss_control(self) -> None: + self.dismiss(None) +''')) +f='src/toad/thread_actions.py' +# Existing lazy import is moved to a module declaration; target_commands remains guarded annotation only. +imp(f,'from toad.widgets.comms_command_dialog import CommandDialog') +patch(f,'ThreadAction.collect',''' from toad.widgets.comms_command_dialog import CommandDialog + +''','') +old=''' def prepare(): + ctx.current() + return TargetEdit(declaration=definition.declaration, + target=definition.declaration.selected_targets(ctx.selection), + arguments=arguments, confirmed=bool(definition.edited(arguments).confirmation), + channel=definition.channels, project=ctx.project) + request = await ctx.app.preparation.run_thread(prepare) + ctx.app.thread_actions.invoke(cls(definition, request, ctx), ctx.title, + source_root=ctx.observed.root)''' +patch(f,'ThreadAction.collect',old,''' await cls.submit(ctx, definition, arguments)''') +insert(f,'ThreadAction.collect',''' @classmethod + async def submit(cls, ctx, definition, arguments): + """Both presentations execute through the original guarded target writer.""" + def prepare(): + ctx.current() + return TargetEdit(declaration=definition.declaration, + target=definition.declaration.selected_targets(ctx.selection), + arguments=arguments, confirmed=bool(definition.edited(arguments).confirmation), + channel=definition.channels, project=ctx.project) + request = await ctx.app.preparation.run_thread(prepare) + ctx.app.thread_actions.invoke(cls(definition, request, ctx), ctx.title, + source_root=ctx.observed.root) + +''') +f='src/toad/forms/editors.py' +patch(f,'SelectEditor.control_arguments','"allow_blank": not listed','"allow_blank": True') +f='src/toad/forms/object_configuration.py' +patch(f,'ResetSettingButton.act','self.field.reset()','self.field.reset()\n self.query_ancestor(ObjectValueView).editor.feedback = ""') +Path('.verification/viewed-object-repair/forms.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'share-the-original-command-editor-and-writer','operations':ops}]},indent=2)) diff --git a/.verification/viewed-object-repair/order.json b/.verification/viewed-object-repair/order.json new file mode 100644 index 000000000..03655dd27 --- /dev/null +++ b/.verification/viewed-object-repair/order.json @@ -0,0 +1,53 @@ +{ + "defect": "The viewed-object bar hides thread features and rule settings and paints inherited values as local edits.", + "why_stupid": "This is slop because the original thread panels, backend configuration commands and ObjectState already own those answers while the replacement bar keeps an incomplete presentation and an Override toggle.", + "unplanned_reason": "Tristan directly reported the missing thread features and wrong inheritance editing behavior after trying the installed bar.", + "slop": [ + { + "pattern": "MEMB-5", + "fact": "The replacement bar exposes an incomplete set instead of composing the original panel and configuration declarations.", + "at": "src/agent_comms/cli_verbs/cli_command.py:90", + "derive": "mixin", + "by": "src/agent_comms/cli_verbs/cli_command.py:90" + } + ], + "order": { + "kind": "pending", + "owner": "src/agent_comms/cli_verbs/cli_command.py:90", + "scope": { + "kind": "fix", + "shape": { + "parents": [ + "src/agent_comms/cli_verbs/cli_command.py:90" + ], + "members": [] + } + }, + "check": { + "directory": "/home/ts/code/projects/agent-comms", + "command": "grep -c '^class ConfigurationCommand' src/agent_comms/cli_verbs/cli_command.py || true", + "result": { + "kind": "count", + "expected": 1 + } + }, + "edits": [ + "Restore the original thread features through their declared panel owners, bind them to the viewed thread and keep channels/files on their own sources.", + "Discover backend configuration forms including rule-gate settings through the existing command owners, sharing their editor and writer.", + "Show inherited values as grey previews; typing/selecting supplies a local value and Reset uses ObjectState signature defaults. Remove the Override toggle.", + "Use native NRA changes, one bounded original UI control and an isolated physical check; ship one coordinated install." + ], + "banned": [ + "Another handwritten setting or panel roster", + "Another Override flag or copied resolved-state store", + "Dropping existing inbound/outbound, family, context or file views", + "Subagents or delegated implementation", + "Broad repeated tests" + ], + "report": "Original owners restored, native plans, focused result, physical evidence, merged source and installed boundary.", + "run": { + "kind": "application", + "text": "The viewed thread exposes its original panels and rule settings; inherited previews remain grey until edited, reset restores signature defaults, and selecting a channel or file never shows stale agent data." + } + } +} diff --git a/.verification/viewed-object-repair/panels.plan.json b/.verification/viewed-object-repair/panels.plan.json new file mode 100644 index 000000000..60c5d7b58 --- /dev/null +++ b/.verification/viewed-object-repair/panels.plan.json @@ -0,0 +1,279 @@ +{ + "recipes": [ + { + "recipe_id": "restore-original-panels-on-the-viewed-thread", + "operations": [ + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_identity.py", + "import_source": "from textual.dom import DOMNode" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_identity.py", + "target_qualname": "SessionIdentity", + "source": "class ThreadIdentityScope(DOMNode):\n \"\"\"The thread described by a panel, independent of the composer sending identity.\"\"\"\n\n @property\n @abstractmethod\n def sidebar_thread_identity(self) -> SessionIdentity: ...\n\n\n" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/session_scope.py", + "import_source": "from toad.thread_identity import ThreadIdentityScope" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/session_scope.py", + "target_qualname": "SessionScope", + "replacements": [ + { + "old_source": "class SessionScope(ProjectPathOwner, NavigationScope, ToadNode):", + "new_source": "class SessionScope(ThreadIdentityScope, ProjectPathOwner, NavigationScope, ToadNode):" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/session_scope.py", + "target_qualname": "SessionScope.belongs_to_wire", + "source": " @property\n def sidebar_thread_identity(self) -> SessionIdentity:\n return self.thread_identity\n\n" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_identity.py", + "import_source": "from toad.thread_identity import ThreadIdentityScope" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_identity.py", + "target_qualname": "ShowsThreadIdentity._on_mount", + "source": " @property\n def thread_identity(self) -> SessionIdentity:\n return self.query_ancestor(ThreadIdentityScope).sidebar_thread_identity\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_identity.py", + "target_qualname": "ShowsThreadIdentity._on_mount", + "replacements": [ + { + "old_source": "self.identity_changed(self.thread_view.thread_identity)", + "new_source": "self.identity_changed(self.thread_identity)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_identity.py", + "target_qualname": "ShowsThreadIdentity.thread_identity_changed", + "replacements": [ + { + "old_source": "self.identity_changed(event.view.thread_identity)", + "new_source": "self.identity_changed(self.thread_identity)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "RelationshipSort.order_writer", + "replacements": [ + { + "old_source": " async def order_writer(self):\n tree = self.query_ancestor(SidebarPanelHost).query_one(ThreadCommsSidebar)\n identity, generation = tree.thread_view.thread_identity, tree._generation\n snapshot = tree._snapshot\n if snapshot is None:\n raise ValueError(\"Relationship ordering is not available yet\")\n groups = snapshot.sortable_groups\n\n async def write(order: type[ThreadSort]):\n service = await tree.relationship_source(identity)\n if (\n generation != tree._generation\n or identity != tree.thread_view.thread_identity\n ):\n return\n try:\n for group in groups:\n await self.app.preparation.run_thread(\n service.set_relationship_order, identity.thread_name(), type(group), order\n )\n finally:\n if (\n generation == tree._generation\n and identity == tree.thread_view.thread_identity\n ):\n tree.refresh_relationships(force=True)\n\n return write\n", + "new_source": " async def order_writer(self):\n tree = self.query_ancestor(SidebarPanelHost).query_one(ThreadCommsSidebar)\n identity, generation = tree.thread_identity, tree._generation\n snapshot = tree._snapshot\n if snapshot is None:\n raise ValueError(\"Relationship ordering is not available yet\")\n groups = snapshot.sortable_groups\n\n async def write(order: type[ThreadSort]):\n service = await tree.relationship_source(identity)\n if (\n generation != tree._generation\n or identity != tree.thread_identity\n ):\n return\n try:\n for group in groups:\n await self.app.preparation.run_thread(\n service.set_relationship_order, identity.thread_name(), type(group), order\n )\n finally:\n if (\n generation == tree._generation\n and identity == tree.thread_identity\n ):\n tree.refresh_relationships(force=True)\n\n return write\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar.view_state", + "replacements": [ + { + "old_source": " def view_state(self):\n return self._states.setdefault(\n self.thread_view.thread_identity, RelationshipTreeState()\n )\n", + "new_source": " def view_state(self):\n return self._states.setdefault(\n self.thread_identity, RelationshipTreeState()\n )\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar.compose", + "replacements": [ + { + "old_source": " def compose(self):\n yield RelationshipContext(\n self.thread_view.thread_identity.display_thread(),\n markup=False,\n )\n self._filter_controls.clear()\n for category in MessageCategory.members_with(MessageCategory):\n checkbox = MessageFilter(\n category.label,\n id=f\"filter-{category.declared_name}\",\n compact=True,\n )\n self._filter_controls[checkbox] = category\n yield checkbox\n", + "new_source": " def compose(self):\n yield RelationshipContext(\n self.thread_identity.display_thread(),\n markup=False,\n )\n self._filter_controls.clear()\n for category in MessageCategory.members_with(MessageCategory):\n checkbox = MessageFilter(\n category.label,\n id=f\"filter-{category.declared_name}\",\n compact=True,\n )\n self._filter_controls[checkbox] = category\n yield checkbox\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar.accepts_observation", + "replacements": [ + { + "old_source": " def accepts_observation(self) -> bool:\n identity = self.thread_view.thread_identity\n return self.panel_visible and self.model_state.serves(identity.root_path)\n", + "new_source": " def accepts_observation(self) -> bool:\n identity = self.thread_identity\n return self.panel_visible and self.model_state.serves(identity.root_path)\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar._refresh", + "replacements": [ + { + "old_source": " async def _refresh(self, generation):\n await self.thread_view.thread_identity.inspect_owner(\n lambda identity: self._refresh_owner(identity, generation),\n lambda text: self.query_one(RelationshipContext).update(\n Content(text), layout=False\n ),\n )\n", + "new_source": " async def _refresh(self, generation):\n await self.thread_identity.inspect_owner(\n lambda identity: self._refresh_owner(identity, generation),\n lambda text: self.query_one(RelationshipContext).update(\n Content(text), layout=False\n ),\n )\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar.navigation_root", + "replacements": [ + { + "old_source": " def navigation_root(self):\n return self.thread_view.thread_identity.root_path\n", + "new_source": " def navigation_root(self):\n return self.thread_identity.root_path\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar.open_target", + "replacements": [ + { + "old_source": " def open_target(self, target: NavigationTarget):\n identity = self.thread_view.thread_identity\n if (\n identity.root_path\n != self.app.coordination_access.observation.observed().root\n ):\n self.notify(\n \"This view uses a different wire; open its matching connection to navigate.\",\n title=\"Comms\",\n severity=\"warning\",\n )\n return\n self.publish_core(SelectTarget(target))\n", + "new_source": " def open_target(self, target: NavigationTarget):\n identity = self.thread_identity\n if (\n identity.root_path\n != self.app.coordination_access.observation.observed().root\n ):\n self.notify(\n \"This view uses a different wire; open its matching connection to navigate.\",\n title=\"Comms\",\n severity=\"warning\",\n )\n return\n self.publish_core(SelectTarget(target))\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/thread_comms.py", + "target_qualname": "ThreadCommsSidebar._navigate_current", + "replacements": [ + { + "old_source": " async def _navigate_current(self, group_key, expected, generation):\n if generation != self._generation or not expected.available:\n return\n identity = self.thread_view.thread_identity\n service = await self.relationship_source(identity)\n snapshot = await asyncio.to_thread(service.relationships, identity.thread_name())\n if (\n generation != self._generation\n or not self.is_attached\n or not self.screen.is_current\n ):\n return\n current = snapshot.current_entry(group_key, expected)\n if current is None:\n self.notify(\n \"This relationship changed; refresh the list before opening it.\",\n title=\"Comms\",\n )\n self.refresh_relationships(force=True)\n return\n self.open_target(entry_target(current))\n", + "new_source": " async def _navigate_current(self, group_key, expected, generation):\n if generation != self._generation or not expected.available:\n return\n identity = self.thread_identity\n service = await self.relationship_source(identity)\n snapshot = await asyncio.to_thread(service.relationships, identity.thread_name())\n if (\n generation != self._generation\n or not self.is_attached\n or not self.screen.is_current\n ):\n return\n current = snapshot.current_entry(group_key, expected)\n if current is None:\n self.notify(\n \"This relationship changed; refresh the list before opening it.\",\n title=\"Comms\",\n )\n self.refresh_relationships(force=True)\n return\n self.open_target(entry_target(current))\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/context_explorer.py", + "target_qualname": "ContextExplorer.intent", + "replacements": [ + { + "old_source": " def intent(self) -> ContextTreeIntent:\n return self._intents.setdefault(self.thread_view.thread_identity, ContextTreeIntent())\n", + "new_source": " def intent(self) -> ContextTreeIntent:\n return self._intents.setdefault(self.thread_identity, ContextTreeIntent())\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/context_explorer.py", + "target_qualname": "ContextExplorer._reading", + "replacements": [ + { + "old_source": " def _reading(self, identity: SessionIdentity):\n return (\n self.is_attached and not get_current_worker().is_cancelled and self.thread_view.thread_identity == identity\n )\n", + "new_source": " def _reading(self, identity: SessionIdentity):\n return (\n self.is_attached and not get_current_worker().is_cancelled and self.thread_identity == identity\n )\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/context_explorer.py", + "target_qualname": "ContextExplorer.read", + "replacements": [ + { + "old_source": " async def read(self, *, force: bool = False) -> None:\n await self.thread_view.thread_identity.inspect_owner(\n lambda identity: self._read_owner(identity, force=force),\n self.query_one(ContextStatus).update,\n )\n # Once this read has stopped, observe again: the revision may have moved while it ran.\n self.call_later(self._observed)\n", + "new_source": " async def read(self, *, force: bool = False) -> None:\n await self.thread_identity.inspect_owner(\n lambda identity: self._read_owner(identity, force=force),\n self.query_one(ContextStatus).update,\n )\n # Once this read has stopped, observe again: the revision may have moved while it ran.\n self.call_later(self._observed)\n" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/context_explorer.py", + "target_qualname": "ContextExplorer._change_page", + "replacements": [ + { + "old_source": " def _change_page(self) -> None:\n # Destination selection ends the acquired page's lifetime now, not\n # when the replacement read finishes. Unacquired inspection states\n # cannot publish old groups, searches or native results.\n self.state = self.thread_view.thread_identity.inspection_state()\n ContextPresentationWork.cancel_all(self)\n ContextNativeWork.cancel_all(self)\n ContextSearchWork.cancel_all(self)\n ContextDetailWork.cancel_all(self)\n self.query_one(ContextOlder).disabled = True\n self.query_one(ContextNewer).disabled = True\n self.query_one(ContextTree).clear()\n self._show_placeholder(\"Loading recorded request page\u2026\")\n self.read()\n", + "new_source": " def _change_page(self) -> None:\n # Destination selection ends the acquired page's lifetime now, not\n # when the replacement read finishes. Unacquired inspection states\n # cannot publish old groups, searches or native results.\n self.state = self.thread_identity.inspection_state()\n ContextPresentationWork.cancel_all(self)\n ContextNativeWork.cancel_all(self)\n ContextSearchWork.cancel_all(self)\n ContextDetailWork.cancel_all(self)\n self.query_one(ContextOlder).disabled = True\n self.query_one(ContextNewer).disabled = True\n self.query_one(ContextTree).clear()\n self._show_placeholder(\"Loading recorded request page\u2026\")\n self.read()\n" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_panels.py", + "target_qualname": "ThreadSessionPanel", + "source": "class ThreadDetailsPanel(SessionPanel, declared_name=None):\n \"\"\"The original thread diagnostics shared by session and DM views.\"\"\"\n\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_panels.py", + "target_qualname": "ThreadSessionPanel", + "replacements": [ + { + "old_source": "class ThreadSessionPanel(SessionPanel[CoordinationStatus]):", + "new_source": "class ThreadSessionPanel(ThreadDetailsPanel, SessionPanel[CoordinationStatus]):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_panels.py", + "target_qualname": "CommsSessionPanel", + "replacements": [ + { + "old_source": "class CommsSessionPanel(SessionPanel[ThreadCommsSidebar]):", + "new_source": "class CommsSessionPanel(ThreadDetailsPanel, SessionPanel[ThreadCommsSidebar]):" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "import_source": "from toad.widgets.session_thread_panels import ThreadDetailsPanel" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "import_source": "from toad.thread_identity import ThreadIdentityScope, NoThreadIdentity" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "target_qualname": "SessionThreadSidebar", + "source": "class ConversationThreadSidebar(ThreadIdentityScope, LazyThreadSidebar):\n \"\"\"A DM's original declared panels describe its peer; channels supply no thread.\"\"\"\n\n def __init__(self, screen, identities):\n self.identities = identities\n self._owner = ref(screen)\n super().__init__(state=screen._thread_sidebar_state)\n\n @property\n def sidebar_thread_identity(self):\n return next(iter(self.identities), NoThreadIdentity())\n\n def make_panels(self):\n screen = self._owner()\n if screen is None:\n raise ReferenceError(\"The conversation sidebar owner has retired\")\n return (member().compose_panel(screen) for identity in self.identities\n for member in SessionPanel.members_with(ThreadDetailsPanel))\n\n\n" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/conversation_kind.py", + "import_source": "from toad.thread_identity import ThreadIdentity" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/conversation_kind.py", + "target_qualname": "ConversationKind.threads_needed", + "source": " def sidebar_identities(self, root: str):\n return ()\n\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/conversation_kind.py", + "target_qualname": "DmConversation.threads_needed", + "source": " def sidebar_identities(self, root: str):\n return (ThreadIdentity(self.peer, root),)\n\n" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/comms.py", + "import_source": "from toad.widgets.session_thread_sidebar import ConversationThreadSidebar" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/screens/comms.py", + "target_qualname": "CommsScreen.compose", + "source": " def make_thread_sidebar(self):\n return ConversationThreadSidebar(self, self.conversation.sidebar_identities(self.wire_root))\n\n" + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/panels_author.py b/.verification/viewed-object-repair/panels_author.py new file mode 100644 index 000000000..49c2044cb --- /dev/null +++ b/.verification/viewed-object-repair/panels_author.py @@ -0,0 +1,96 @@ +import ast,json +from pathlib import Path +root=Path.cwd();ops=[] +def patch(f,t,o,n):ops.append(dict(operation='patch_target',file_path=str(root/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def insert(f,t,s):ops.append(dict(operation='insert_before_target',file_path=str(root/f),target_qualname=t,source=s)) +def imp(f,s):ops.append(dict(operation='ensure_import',file_path=str(root/f),import_source=s)) +f='src/toad/thread_identity.py' +imp(f,'from textual.dom import DOMNode') +insert(f,'SessionIdentity','''class ThreadIdentityScope(DOMNode): + """The thread described by a panel, independent of the composer sending identity.""" + + @property + @abstractmethod + def sidebar_thread_identity(self) -> SessionIdentity: ... + + +''') +f='src/toad/session_scope.py' +imp(f,'from toad.thread_identity import ThreadIdentityScope') +patch(f,'SessionScope','class SessionScope(ProjectPathOwner, NavigationScope, ToadNode):','class SessionScope(ThreadIdentityScope, ProjectPathOwner, NavigationScope, ToadNode):') +insert(f,'SessionScope.belongs_to_wire',''' @property + def sidebar_thread_identity(self) -> SessionIdentity: + return self.thread_identity + +''') +f='src/toad/widgets/thread_identity.py' +imp(f,'from toad.thread_identity import ThreadIdentityScope') +insert(f,'ShowsThreadIdentity._on_mount',''' @property + def thread_identity(self) -> SessionIdentity: + return self.query_ancestor(ThreadIdentityScope).sidebar_thread_identity + +''') +patch(f,'ShowsThreadIdentity._on_mount','self.identity_changed(self.thread_view.thread_identity)','self.identity_changed(self.thread_identity)') +patch(f,'ShowsThreadIdentity.thread_identity_changed','self.identity_changed(event.view.thread_identity)','self.identity_changed(self.thread_identity)') +# Migrate every consumer of the actual panel capability, retaining existing access to session/project owners. +for f in ['src/toad/widgets/thread_comms.py','src/toad/widgets/context_explorer.py']: + tree=ast.parse((root/f).read_text());lines=(root/f).read_text().splitlines(keepends=True) + for cls in tree.body: + if isinstance(cls,ast.ClassDef): + for method in cls.body: + if isinstance(method,(ast.FunctionDef,ast.AsyncFunctionDef)): + body=''.join(lines[method.lineno-1:method.end_lineno]) + for old,new in [('self.thread_view.thread_identity','self.thread_identity'),('tree.thread_view.thread_identity','tree.thread_identity')]: + if old in body: + updated=body.replace(old,new) + patch(f,cls.name+'.'+method.name,body,updated) + body=updated +f='src/toad/widgets/session_thread_panels.py' +insert(f,'ThreadSessionPanel','''class ThreadDetailsPanel(SessionPanel, declared_name=None): + """The original thread diagnostics shared by session and DM views.""" + + +''') +patch(f,'ThreadSessionPanel','class ThreadSessionPanel(SessionPanel[CoordinationStatus]):','class ThreadSessionPanel(ThreadDetailsPanel, SessionPanel[CoordinationStatus]):') +patch(f,'CommsSessionPanel','class CommsSessionPanel(SessionPanel[ThreadCommsSidebar]):','class CommsSessionPanel(ThreadDetailsPanel, SessionPanel[ThreadCommsSidebar]):') +f='src/toad/widgets/session_thread_sidebar.py' +imp(f,'from toad.widgets.session_thread_panels import ThreadDetailsPanel') +imp(f,'from toad.thread_identity import ThreadIdentityScope, NoThreadIdentity') +insert(f,'SessionThreadSidebar','''class ConversationThreadSidebar(ThreadIdentityScope, LazyThreadSidebar): + """A DM's original declared panels describe its peer; channels supply no thread.""" + + def __init__(self, screen, identities): + self.identities = identities + self._owner = ref(screen) + super().__init__(state=screen._thread_sidebar_state) + + @property + def sidebar_thread_identity(self): + return next(iter(self.identities), NoThreadIdentity()) + + def make_panels(self): + screen = self._owner() + if screen is None: + raise ReferenceError("The conversation sidebar owner has retired") + return (member().compose_panel(screen) for identity in self.identities + for member in SessionPanel.members_with(ThreadDetailsPanel)) + + +''') +f='src/toad/conversation_kind.py' +imp(f,'from toad.thread_identity import ThreadIdentity') +insert(f,'ConversationKind.threads_needed',''' def sidebar_identities(self, root: str): + return () + +''') +insert(f,'DmConversation.threads_needed',''' def sidebar_identities(self, root: str): + return (ThreadIdentity(self.peer, root),) + +''') +f='src/toad/screens/comms.py' +imp(f,'from toad.widgets.session_thread_sidebar import ConversationThreadSidebar') +insert(f,'CommsScreen.compose',''' def make_thread_sidebar(self): + return ConversationThreadSidebar(self, self.conversation.sidebar_identities(self.wire_root)) + +''') +Path('.verification/viewed-object-repair/panels.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'restore-original-panels-on-the-viewed-thread','operations':ops}]},indent=2)) diff --git a/.verification/viewed-object-repair/peer.plan.json b/.verification/viewed-object-repair/peer.plan.json new file mode 100644 index 000000000..97015691e --- /dev/null +++ b/.verification/viewed-object-repair/peer.plan.json @@ -0,0 +1,58 @@ +{ + "recipes": [ + { + "recipe_id": "retain-peer-birth-through-current-reference-resolution", + "operations": [ + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "import_source": "from agent_comms.messaging.display_references import ThreadReferenceReader" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "import_source": "from toad.thread_identity import LocalThreadIdentity, SessionIdentity" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "target_qualname": "ConversationThreadSidebar", + "source": "class SidebarThreadIdentity(ThreadReferenceReader[SessionIdentity]):\n \"\"\"A retired peer's old name must never address a replacement thread.\"\"\"\n\n def __init__(self, root):\n self.root = root\n\n def current(self, reference):\n return ThreadIdentity(reference.incarnation, self.root)\n\n def retired(self, reference):\n return LocalThreadIdentity(f\"{reference.name} (gone)\")\n\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "target_qualname": "ConversationThreadSidebar.__init__", + "replacements": [ + { + "old_source": "self.identities = tuple(ThreadIdentity(thread, screen.wire_root) for thread in threads)", + "new_source": "self.threads = threads" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "target_qualname": "ConversationThreadSidebar.sidebar_thread_identity", + "replacements": [ + { + "old_source": " return next(iter(self.identities), NoThreadIdentity())", + "new_source": " screen = self._owner()\n if screen is None:\n raise ReferenceError(\"The conversation sidebar owner has retired\")\n references = screen.app.coordination_access.observation.observed().sidebar.observation.thread_names()\n return next((references.thread(thread).read(SidebarThreadIdentity(screen.wire_root))\n for thread in self.threads), NoThreadIdentity())" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/session_thread_sidebar.py", + "target_qualname": "ConversationThreadSidebar.make_panels", + "replacements": [ + { + "old_source": "for identity in self.identities", + "new_source": "for thread in self.threads" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/peer_author.py b/.verification/viewed-object-repair/peer_author.py new file mode 100644 index 000000000..e7d6f2970 --- /dev/null +++ b/.verification/viewed-object-repair/peer_author.py @@ -0,0 +1,30 @@ +import json +from pathlib import Path +r=Path.cwd();f='src/toad/widgets/session_thread_sidebar.py';ops=[] +def p(t,a,b):ops.append(dict(operation='patch_target',file_path=str(r/f),target_qualname=t,replacements=[dict(old_source=a,new_source=b)])) +def i(s):ops.append(dict(operation='ensure_import',file_path=str(r/f),import_source=s)) +i('from agent_comms.messaging.display_references import ThreadReferenceReader') +i('from toad.thread_identity import LocalThreadIdentity, SessionIdentity') +ops.append(dict(operation='insert_before_target',file_path=str(r/f),target_qualname='ConversationThreadSidebar',source='''class SidebarThreadIdentity(ThreadReferenceReader[SessionIdentity]): + """A retired peer's old name must never address a replacement thread.""" + + def __init__(self, root): + self.root = root + + def current(self, reference): + return ThreadIdentity(reference.incarnation, self.root) + + def retired(self, reference): + return LocalThreadIdentity(f"{reference.name} (gone)") + + +''')) +p('ConversationThreadSidebar.__init__','self.identities = tuple(ThreadIdentity(thread, screen.wire_root) for thread in threads)','self.threads = threads') +p('ConversationThreadSidebar.sidebar_thread_identity',''' return next(iter(self.identities), NoThreadIdentity())''',''' screen = self._owner() + if screen is None: + raise ReferenceError("The conversation sidebar owner has retired") + references = screen.app.coordination_access.observation.observed().sidebar.observation.thread_names() + return next((references.thread(thread).read(SidebarThreadIdentity(screen.wire_root)) + for thread in self.threads), NoThreadIdentity())''') +p('ConversationThreadSidebar.make_panels','for identity in self.identities','for thread in self.threads') +Path('.verification/viewed-object-repair/peer.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'retain-peer-birth-through-current-reference-resolution','operations':ops}]},indent=2)) diff --git a/.verification/viewed-object-repair/preview.plan.json b/.verification/viewed-object-repair/preview.plan.json new file mode 100644 index 000000000..04ef74a7e --- /dev/null +++ b/.verification/viewed-object-repair/preview.plan.json @@ -0,0 +1,164 @@ +{ + "recipes": [ + { + "recipe_id": "paint-inherited-previews-and-reset-the-original-parameter", + "operations": [ + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "import_source": "from ui_theme.scheme import MutedColour" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "ValueEditor.control_mounted", + "source": " @property\n def previewing(self) -> bool:\n \"\"\"The raw field inherits; its resolved value is only a preview.\"\"\"\n return self.field.state.parameters[self.field.path.value] is None and self.field.value is not None\n\n def paint_value(self) -> None:\n self.paint()\n self.styles.color = self.app.colours.colour(MutedColour) if self.previewing else None\n self.styles.text_style = \"dim\" if self.previewing else \"\"\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "ValueEditor.control_mounted", + "replacements": [ + { + "old_source": "self.call_after_refresh(self.paint)", + "new_source": "self.call_after_refresh(self.paint_value)" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "LineEditor.control_arguments", + "source": " def current_text(self) -> str:\n return \"\" if self.previewing else self.value_text(self.field.value)\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "LineEditor.control_arguments", + "replacements": [ + { + "old_source": " \"value\": self.value_text(self.field.value),", + "new_source": " \"value\": self.current_text(),\n \"placeholder\": self.value_text(self.field.value) if self.previewing else self.field.placeholder," + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "LineEditor.finish", + "replacements": [ + { + "old_source": "self.value == self.value_text(self.field.value)", + "new_source": "self.value == self.current_text()" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "LineEditor.paint", + "replacements": [ + { + "old_source": " self.value = self.value_text(self.field.value)", + "new_source": " self.placeholder = self.value_text(self.field.value) if self.previewing else self.field.placeholder\n self.value = self.current_text()" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "SelectEditor.control_arguments", + "replacements": [ + { + "old_source": " listed = any(value == choice for _, choice in options)", + "new_source": " listed = not self.previewing and any(value == choice for _, choice in options)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "SelectEditor.control_arguments", + "replacements": [ + { + "old_source": " \"prompt\": self.field.placeholder if value is None else str(value),", + "new_source": " \"prompt\": next((label for label, choice in options if choice == value),\n self.field.placeholder if value is None else str(value))," + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "SelectEditor.paint", + "replacements": [ + { + "old_source": " self.value = value if self.lists(value) else Select.NULL", + "new_source": " self.value = value if not self.previewing and self.lists(value) else Select.NULL" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/form_view.py", + "target_qualname": "FormView.values_changed", + "replacements": [ + { + "old_source": "editor.paint()", + "new_source": "editor.paint_value()" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/object_configuration.py", + "target_qualname": "ObjectSettingField.edit", + "source": " def reset(self) -> None:\n \"\"\"The writer resets its actual parameter to that parameter's signature default.\"\"\"\n state = self.state if self.allows_override else self.state.defaults\n state.reset_parameter(self.path.value)\n if state is self.state.defaults:\n self.state.global_settings.save_sync()\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/object_configuration.py", + "target_qualname": "InheritSettingButton", + "replacements": [ + { + "old_source": "class InheritSettingButton(ActingButton):\n def __init__(self, field):\n super().__init__(\"Override here\")\n self.field = field\n\n async def act(self):\n if self.field.state.parameters[self.field.path.value] is None:\n self.field.edit(self.field.value)\n else:\n self.field.state.reset_parameter(self.field.path.value)", + "new_source": "class ResetSettingButton(ActingButton):\n \"\"\"Reset uses the parameter's own signature, including lazy None defaults.\"\"\"\n def __init__(self, field):\n super().__init__(\"Reset\")\n self.field = field\n\n async def act(self):\n self.field.reset()" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/object_configuration.py", + "target_qualname": "ObjectValueView.__init__", + "replacements": [ + { + "old_source": "self.inherit = InheritSettingButton(field)", + "new_source": "self.reset = ResetSettingButton(field)" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/object_configuration.py", + "target_qualname": "ObjectValueView.compose", + "replacements": [ + { + "old_source": "yield self.inherit", + "new_source": "yield self.reset" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/object_configuration.py", + "target_qualname": "ObjectValueView.mark", + "replacements": [ + { + "old_source": " self.inherit.display = local\n self.inherit.label = \"Use global default\" if explicit else \"Override here\"", + "new_source": " if self.editor.is_mounted:\n self.editor.paint_value()" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/test-label.plan.json b/.verification/viewed-object-repair/test-label.plan.json new file mode 100644 index 000000000..97917b9a3 --- /dev/null +++ b/.verification/viewed-object-repair/test-label.plan.json @@ -0,0 +1,20 @@ +{ + "recipes": [ + { + "recipe_id": "remove-test-line-whitespace", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": "assert field_view.reset.label.plain == 'Reset' ", + "new_source": "assert str(field_view.reset.label) == 'Reset'" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/toad/configured_view.py b/src/toad/configured_view.py index 8136b2a4a..3968fdd7d 100644 --- a/src/toad/configured_view.py +++ b/src/toad/configured_view.py @@ -14,7 +14,9 @@ from toad.forms.presentation import FormModal from toad.session_tracker import SidebarState from toad.settings_scope import ViewSettingsScope +from toad.thread_actions import ThreadAction from toad.widgets.acting_button import DismissButton +from toad.widgets.comms_command_dialog import CommandForm from toad.widgets.comms_menu import ActionMenu from toad.widgets.model_switcher import SessionForm from toad.widgets.side_bar import ( @@ -86,10 +88,25 @@ def finish(self): self.form.finish() +class ActionProperties(CommandForm, SidebarPanelWidget): + """The original backend operation form, using its original target writer.""" + + def __init__(self, context, definition, fields): + self.context = context + super().__init__(definition, context.title, fields, context.observed.core) + + @property + def panel_identity(self): + return self.definition.declaration + + async def submit(self, arguments): + await ThreadAction.submit(self.context, self.definition, arguments) + + class ObjectConfigurationDialog(FormModal[None], DismissibleScreen[None]): - def __init__(self, panels): + def __init__(self, view): super().__init__() - self.form = PanelMenu(panels, SidebarState()) + self.form = PanelMenu(view.configuration_panels, SidebarState(), view) def compose(self): with ModalPanel(): @@ -145,4 +162,4 @@ def configuration_menu(self, event): self.display_name, [(self.open_configuration, "Properties…")]) def open_configuration(self): - self.app.push_screen(ObjectConfigurationDialog(self.configuration_panels)) + self.app.push_screen(ObjectConfigurationDialog(self)) diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index 902849e05..2ebb2ea35 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -68,6 +68,9 @@ def destination(self, references: ThreadNames) -> NamedTarget: ... @abstractmethod def retained_target(self) -> NamedTarget: ... + def sidebar_threads(self): + return () + def threads_needed(self, source: ReferencedCore, me: str): return () @@ -250,6 +253,9 @@ def historical_sessions[View](self, create_view: partial[View], references: Thre async def toggle_dm(cls, screen: ConversationNavigation) -> None: await screen.return_to_view() + def sidebar_threads(self): + return (self.peer,) + def threads_needed(self, source: ReferencedCore, me: str): return source.registry_for(me).require(me).incarnation, self.peer diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 3b8f01189..f9f83b389 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -41,6 +41,7 @@ FramedControl, ) from toad.tone_paint import UntintedTonePaint +from ui_theme.scheme import MutedColour if TYPE_CHECKING: EditorWidget = Widget @@ -72,8 +73,18 @@ def __init__(self, field: DeclaredField) -> None: self.bind_control() self.listen(events.Mount, self.control_mounted) + @property + def previewing(self) -> bool: + """The raw field inherits; its resolved value is only a preview.""" + return self.field.state.parameters[self.field.path.value] is None and self.field.value is not None + + def paint_value(self) -> None: + self.paint() + self.styles.color = self.app.colours.colour(MutedColour) if self.previewing else None + self.styles.text_style = "dim" if self.previewing else "" + def control_mounted(self, event: events.Mount) -> None: - self.call_after_refresh(self.paint) + self.call_after_refresh(self.paint_value) def control_arguments(self) -> dict[str, object]: """Arguments for the native control; containers need no initial value.""" @@ -139,9 +150,13 @@ class LineEditor(ValueEditor, FormInput): accepts: ClassVar[InputType] + def current_text(self) -> str: + return "" if self.previewing else self.value_text(self.field.value) + def control_arguments(self) -> dict[str, object]: return { - "value": self.value_text(self.field.value), + "value": self.current_text(), + "placeholder": self.value_text(self.field.value) if self.previewing else self.field.placeholder, "type": self.accepts, "validators": [Function(self.acceptable)], } @@ -167,7 +182,7 @@ def finished(self, message: Input.Blurred | Input.Submitted) -> None: self.finish() def finish(self) -> None: - if self.value == self.value_text(self.field.value): + if self.value == self.current_text(): return try: value = self.read(self.value) @@ -177,7 +192,8 @@ def finish(self) -> None: self.send(value) def paint(self) -> None: - self.value = self.value_text(self.field.value) + self.placeholder = self.value_text(self.field.value) if self.previewing else self.field.placeholder + self.value = self.current_text() class IntEditor(LineEditor, shows=(int,)): @@ -241,12 +257,13 @@ def watch_has_focus(self, has_focus: bool) -> None: def control_arguments(self) -> dict[str, object]: options = tuple(self.options()) value = self.field.value - listed = any(value == choice for _, choice in options) + listed = not self.previewing and any(value == choice for _, choice in options) return { "options": options, "value": value if listed else Select.NULL, - "allow_blank": not listed, - "prompt": self.field.placeholder if value is None else str(value), + "allow_blank": True, + "prompt": next((label for label, choice in options if choice == value), + self.field.placeholder if value is None else str(value)), } def bind_control(self) -> None: @@ -274,7 +291,7 @@ def paint(self) -> None: value = self.field.value with self.prevent(Select.Changed): self.prompt = self.value_text(value) - self.value = value if self.lists(value) else Select.NULL + self.value = value if not self.previewing and self.lists(value) else Select.NULL class ChoicesEditor(SelectEditor, shows=(AnnotationChoices,)): diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 752a0efb6..2fe8239b8 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -239,7 +239,7 @@ def values_changed(self, paths: set[str]) -> None: for path in paths & self.values.keys(): editor = self.values[path].editor if editor.is_mounted: - editor.paint() + editor.paint_value() def marks_changed(self, paths: set[str]) -> None: differ = self.state.dirty_fields diff --git a/src/toad/forms/object_configuration.py b/src/toad/forms/object_configuration.py index 95615debd..6da7e3061 100644 --- a/src/toad/forms/object_configuration.py +++ b/src/toad/forms/object_configuration.py @@ -17,36 +17,35 @@ def family(cls): -class InheritSettingButton(ActingButton): +class ResetSettingButton(ActingButton): + """Reset uses the parameter's own signature, including lazy None defaults.""" def __init__(self, field): - super().__init__("Override here") + super().__init__("Reset") self.field = field async def act(self): - if self.field.state.parameters[self.field.path.value] is None: - self.field.edit(self.field.value) - else: - self.field.state.reset_parameter(self.field.path.value) + self.field.reset() + self.query_ancestor(ObjectValueView).editor.feedback = "" class ObjectValueView(ValueView, ObjectFieldView, shows=(ParameterInfo,)): def __init__(self, field): super().__init__(field) self.source = Static("", markup=False) - self.inherit = InheritSettingButton(field) + self.reset = ResetSettingButton(field) self.listen(events.Mount, lambda event: self.mark(False)) def compose(self): yield from super().compose() yield self.source - yield self.inherit + yield self.reset def mark(self, changed): local = self.field.allows_override explicit = local and self.field.state.parameters[self.field.path.value] is not None super().mark(explicit) - self.inherit.display = local - self.inherit.label = "Use global default" if explicit else "Override here" + if self.editor.is_mounted: + self.editor.paint_value() if explicit: self.source.update("Override for this object") else: diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 2a9b02206..e1d9bf1ae 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -53,6 +53,10 @@ def menu_context(self, sidebar, *, channel: ChannelSelection, mode_name=None): sidebar.app.workspace_sessions.navigation_owner().navigation_context.actor, sidebar.app.project_dir, mode_name, channel) + def configuration_contexts(self, view): + """A destination without a configurable subject supplies no command context.""" + return () + def matches(self, target: NavigationTarget) -> bool: """Different opening behaviors may display the same sidebar address.""" return self.name == target.name @@ -92,7 +96,18 @@ async def open(self, context: NavigationContext) -> str: return app.selected_mode -class ThreadTarget(NavigationTarget): +class ConfigurationTarget: + """Named thread and history destinations borrow Core's configuration membership.""" + + def configuration_contexts(self, view): + navigation = view.navigation_context + observed = view.app.coordination_access.observing(view.coordination_root) + return (TargetContext(view.app, observed, CommandTarget.named(self.name), + navigation.actor, navigation.project_path, + navigation.owner_mode),) + + +class ThreadTarget(ConfigurationTarget, NavigationTarget): async def open(self, context: NavigationContext) -> str: return await context.app.thread_navigation.open( owner_mode=context.owner_mode, project_path=context.project_path, target=self, @@ -125,7 +140,7 @@ class HistoryRoute: @abstractmethod def for_name(cls, name: str) -> HistoryTarget: ... -class HistoryTarget(HistoryRoute, NavigationTarget): +class HistoryTarget(ConfigurationTarget, HistoryRoute, NavigationTarget): @classmethod def for_name(cls, name: str) -> HistoryTarget: return cls(name) diff --git a/src/toad/object_configuration.py b/src/toad/object_configuration.py index 223eb2f67..ad7c53303 100644 --- a/src/toad/object_configuration.py +++ b/src/toad/object_configuration.py @@ -38,6 +38,13 @@ def owner_type(self): def allows_override(self) -> bool: return self.state.admits(self.path.value) + def reset(self) -> None: + """The writer resets its actual parameter to that parameter's signature default.""" + state = self.state if self.allows_override else self.state.defaults + state.reset_parameter(self.path.value) + if state is self.state.defaults: + self.state.global_settings.save_sync() + def edit(self, value): if self.allows_override: return super().edit(value) diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 67f078240..e10f6f854 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -37,6 +37,7 @@ from toad.thread_identity import SessionIdentity from toad.widget_actions import CyclesSessions, TogglesIrc, WidgetAction from toad.widgets.comms_chat import CommsChatView +from toad.widgets.session_thread_sidebar import ConversationThreadSidebar from toad.widgets.side_bar import SideBar, ThreadSidebar from toad.worker_group import WorkerGroup @@ -131,6 +132,9 @@ def navigation_target(self) -> NavigationTarget: + def make_thread_sidebar(self): + return ConversationThreadSidebar(self, self.conversation.sidebar_threads()) + def compose(self) -> ComposeResult: content = ( (self.create_chat(),) diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index d84fef223..f6da12970 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -6,7 +6,7 @@ from textual.view import FILL, WidgetView from textual.color import Color from textual.layout import Layout -from toad.configured_view import ConfiguredView +from toad.configured_view import ActionProperties, ConfiguredView from toad.settings_scope import ConversationSettingsScope from ui_theme.scheme import BackgroundColour from textual.reactive import reactive @@ -212,6 +212,18 @@ class SidebarSessionView[SidebarT: ThreadSidebar](ConfiguredView, ConversationSe width = height = FILL layer_order = ("sidebar", "screen", "base") + async def configuration_action_panels(self): + def read(): + entries = [] + for context in self.navigation_target.configuration_contexts(self): + for action in context.available_actions(): + for configuration in action.declaration.configuration_actions(action): + entries.append((context, configuration, configuration.editable_fields)) + return entries + declarations = await self.app.preparation.run_thread(read) + return tuple(SideBar.Panel(action.label, ActionProperties(context, action, fields), flex=True) + for context, action, fields in declarations) + @property def paint_background(self) -> Color: return self.app.color_system.color(BackgroundColour.name) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index d81ff0305..ed8bfdfeb 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -22,7 +22,7 @@ from toad.navigation_target import NavigationTarget from toad.launch_binding import AgentBinding, AgentOrigin, NoAgent -from toad.thread_identity import SessionIdentity +from toad.thread_identity import SessionIdentity, ThreadIdentityScope class SessionOrigin(metaclass=AbstractHooksMeta): """An origin answers for itself, including when its view is unavailable.""" @@ -51,7 +51,7 @@ def resolve_thread(self) -> Never: self.require() -class SessionScope(ProjectPathOwner, NavigationScope, ToadNode): +class SessionScope(ThreadIdentityScope, ProjectPathOwner, NavigationScope, ToadNode): """The session answers read by ancestors and workspace owners. A compositing layer: the compositor retains this subtree's geometry while @@ -98,6 +98,10 @@ def origin(self) -> SessionOrigin: ... @abstractmethod def thread_identity(self) -> SessionIdentity: ... + @property + def sidebar_thread_identity(self) -> SessionIdentity: + return self.thread_identity + @abstractmethod def belongs_to_wire(self, root: Path) -> bool: ... diff --git a/src/toad/settings_scope.py b/src/toad/settings_scope.py index 8970c8e3e..72fd1b0ef 100644 --- a/src/toad/settings_scope.py +++ b/src/toad/settings_scope.py @@ -35,6 +35,10 @@ class ViewSettingsScope(SettingsScope): @abstractmethod def configuration_panels(self): ... + async def configuration_action_panels(self): + """Backend configuration is absent unless the viewed object supplies targets.""" + return () + class ConversationSettingsScope(ViewSettingsScope): """A thread or channel additionally owns conversation and composer settings.""" diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index 7f02248c3..cf4a09f19 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -18,6 +18,7 @@ ) from toad.core import events as core_events +from toad.widgets.comms_command_dialog import CommandDialog if TYPE_CHECKING: from toad.app import ToadApp @@ -116,23 +117,26 @@ async def completed(self, app, selected, result: CliResult): result.show(app, self.definition.label, self.context.title) @classmethod - def collect(cls, ctx, definition): - from toad.widgets.comms_command_dialog import CommandDialog + async def submit(cls, ctx, definition, arguments): + """Both presentations execute through the original guarded target writer.""" + def prepare(): + ctx.current() + return TargetEdit(declaration=definition.declaration, + target=definition.declaration.selected_targets(ctx.selection), + arguments=arguments, confirmed=bool(definition.edited(arguments).confirmation), + channel=definition.channels, project=ctx.project) + request = await ctx.app.preparation.run_thread(prepare) + ctx.app.thread_actions.invoke(cls(definition, request, ctx), ctx.title, + source_root=ctx.observed.root) + @classmethod + def collect(cls, ctx, definition): ctx = replace(ctx, mode=ctx.mode or ctx.app.selected_mode) async def accepted(arguments): if arguments is None: return - def prepare(): - ctx.current() - return TargetEdit(declaration=definition.declaration, - target=definition.declaration.selected_targets(ctx.selection), - arguments=arguments, confirmed=bool(definition.edited(arguments).confirmation), - channel=definition.channels, project=ctx.project) - request = await ctx.app.preparation.run_thread(prepare) - ctx.app.thread_actions.invoke(cls(definition, request, ctx), ctx.title, - source_root=ctx.observed.root) + await cls.submit(ctx, definition, arguments) async def prepare(): if definition.editable_fields or definition.confirmation: diff --git a/src/toad/thread_identity.py b/src/toad/thread_identity.py index 040e9f2db..c19c1fe74 100644 --- a/src/toad/thread_identity.py +++ b/src/toad/thread_identity.py @@ -14,6 +14,7 @@ UnrecordedThreadIncarnation, ) +from textual.dom import DOMNode from toad.format_path import format_path from toad.transcript_scope import KnownTranscript, NoTranscript, TranscriptScope from toad.transcript_state import UnboundTranscript, UnreadTranscript @@ -23,6 +24,14 @@ +class ThreadIdentityScope(DOMNode): + """The thread described by a panel, independent of the composer sending identity.""" + + @property + @abstractmethod + def sidebar_thread_identity(self) -> SessionIdentity: ... + + class SessionIdentity(ABC): @classmethod def for_thread(cls, thread: str, root: str | None): diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 71718188c..3e91a077b 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from abc import abstractmethod from dataclasses import dataclass, field, make_dataclass from typing import Annotated, get_args, get_origin @@ -21,7 +22,7 @@ from python_introspect.choices import LabelledChoices from textual import on, work from textual.app import ComposeResult -from textual.containers import Horizontal, VerticalScroll +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.css.scalar import Scalar from textual.document._editing import EditingMode from textual.events import Mount, Unmount @@ -44,7 +45,7 @@ ModalScreen, ShadedScreen, ) -from toad.widget_actions import WidgetAction +from toad.widget_actions import DismissibleControl, WidgetAction from toad.widgets.acting_button import ConfirmButton, Confirms, DismissButton from toad.widgets.checkbox import Checkbox from toad.widgets.model_selection import ModelSelection @@ -130,7 +131,7 @@ def dismiss_control(self) -> None: @work(exclusive=True) async def connect_provider(self): - command = self.editor.query_ancestor(CommandDialog) + command = self.editor.query_ancestor(CommandForm) with self.app.suspend(): result = await asyncio.to_thread(run_login) if result == 0 and self.is_attached: @@ -161,7 +162,7 @@ class ApplyCommandAction(ViewAction): keys = {EditingMode: ('ctrl+enter',)} show = True - async def apply(self, context: CommandDialog) -> None: + async def apply(self, context: CommandForm) -> None: await context.confirm() @@ -217,7 +218,8 @@ class CommandPreparation(WorkerGroup): """One preparation follows the current draft without restarting on each edit.""" -class CommandDialog(Confirms, ShadedScreen, CenteredScreen, DismissibleScreen[dict[str, str | None]], actions=(ApplyCommandAction,)): +class CommandForm(Confirms, DismissibleControl, Vertical, actions=(ApplyCommandAction,)): + """One backend operation, embedded in a panel or presented as a dialog.""" def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetField, ...], core: CoreAccess): super().__init__() @@ -239,15 +241,25 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi self.form = InlineFormView.whole(self.state) def compose(self) -> ComposeResult: - with CommandBox(): - yield CommandTitle(f"{self.definition.label} — {self.target}", markup=False) - with CommandFormScroll(): - yield CommandConfirmation('', markup=False) - yield self.form - yield CommandConfirmed('I confirm this operation') - with CommandButtons(): - yield ApplyCommandButton('Apply', variant='primary') - yield CancelCommandButton('Cancel') + yield CommandTitle(f"{self.definition.label} — {self.target}", markup=False) + with CommandFormScroll(): + yield CommandConfirmation('', markup=False) + yield self.form + yield CommandConfirmed('I confirm this operation') + with CommandButtons(): + yield ApplyCommandButton('Apply', variant='primary') + yield CancelCommandButton('Cancel') + + @abstractmethod + async def submit(self, arguments: dict[str, str | None]) -> None: ... + + def finish(self) -> None: + self.form.finish() + + def dismiss_control(self) -> None: + """An embedded form discards its draft without closing the containing view.""" + for path in self.state.parameters: + self.state.reset_parameter(path) async def confirm(self) -> None: button = self.query_one(ApplyCommandButton) @@ -273,7 +285,7 @@ async def confirm(self) -> None: self.update_confirmation() return if self.is_attached and arguments == self.arguments(): - self.dismiss(arguments) + await self.submit(arguments) finally: if self.is_attached: button.disabled = False @@ -358,3 +370,17 @@ def read(): self.query_one(CommandConfirmation).update(confirmation) confirmed.display = valid and bool(confirmation) self.run_worker(prepare, group='command-confirmation', exclusive=True) + + +class CommandDialog(CommandForm, ShadedScreen, CenteredScreen, DismissibleScreen[dict[str, str | None]]): + """The shared operation form in its modal presentation.""" + + def compose(self) -> ComposeResult: + with CommandBox(): + yield from super().compose() + + async def submit(self, arguments: dict[str, str | None]) -> None: + self.dismiss(arguments) + + def dismiss_control(self) -> None: + self.dismiss(None) diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 6a9909e9f..95b7b5697 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -442,7 +442,7 @@ def __init__(self, *, intents: dict[SessionIdentity, ContextTreeIntent]) -> None @property def intent(self) -> ContextTreeIntent: - return self._intents.setdefault(self.thread_view.thread_identity, ContextTreeIntent()) + return self._intents.setdefault(self.thread_identity, ContextTreeIntent()) def compose(self): yield ContextStatus( @@ -522,12 +522,12 @@ def refresh_context(self) -> None: def _reading(self, identity: SessionIdentity): return ( - self.is_attached and not get_current_worker().is_cancelled and self.thread_view.thread_identity == identity + self.is_attached and not get_current_worker().is_cancelled and self.thread_identity == identity ) @ContextReadWork.work async def read(self, *, force: bool = False) -> None: - await self.thread_view.thread_identity.inspect_owner( + await self.thread_identity.inspect_owner( lambda identity: self._read_owner(identity, force=force), self.query_one(ContextStatus).update, ) @@ -626,7 +626,7 @@ def _change_page(self) -> None: # Destination selection ends the acquired page's lifetime now, not # when the replacement read finishes. Unacquired inspection states # cannot publish old groups, searches or native results. - self.state = self.thread_view.thread_identity.inspection_state() + self.state = self.thread_identity.inspection_state() ContextPresentationWork.cancel_all(self) ContextNativeWork.cancel_all(self) ContextSearchWork.cancel_all(self) diff --git a/src/toad/widgets/session_thread_panels.py b/src/toad/widgets/session_thread_panels.py index 3020b605d..5d30a9ec2 100644 --- a/src/toad/widgets/session_thread_panels.py +++ b/src/toad/widgets/session_thread_panels.py @@ -39,14 +39,18 @@ def capture(self, widget: W) -> None: """Stateless projections have no independent state to retain.""" -class ThreadSessionPanel(SessionPanel[CoordinationStatus]): +class ThreadDetailsPanel(SessionPanel, declared_name=None): + """The original thread diagnostics shared by session and DM views.""" + + +class ThreadSessionPanel(ThreadDetailsPanel, SessionPanel[CoordinationStatus]): panel_id = "coordination-panel" def make_widget(self, screen: MainScreen) -> CoordinationStatus: return CoordinationStatus() -class CommsSessionPanel(SessionPanel[ThreadCommsSidebar]): +class CommsSessionPanel(ThreadDetailsPanel, SessionPanel[ThreadCommsSidebar]): def __init__(self) -> None: self.states: dict[SessionIdentity, RelationshipTreeState] = {} diff --git a/src/toad/widgets/session_thread_sidebar.py b/src/toad/widgets/session_thread_sidebar.py index 8f2c55fc0..46e775993 100644 --- a/src/toad/widgets/session_thread_sidebar.py +++ b/src/toad/widgets/session_thread_sidebar.py @@ -1,14 +1,26 @@ """Session side panels retain reader position while their view is parked.""" from typing import TYPE_CHECKING from weakref import ref +from agent_comms.messaging.display_references import ThreadReferenceReader from textual.worker import WorkerCancelled from toad.plan import PlanItem +from toad.thread_identity import ( + LocalThreadIdentity, + NoThreadIdentity, + SessionIdentity, + ThreadIdentity, + ThreadIdentityScope, +) from toad.widgets.side_bar import ( AbsentPanels, LazyThreadSidebar, PanelHydration, ) -from toad.widgets.session_thread_panels import PlanSessionPanel, SessionPanel +from toad.widgets.session_thread_panels import ( + PlanSessionPanel, + SessionPanel, + ThreadDetailsPanel, +) from toad.widgets.presentation_window import NativePresentation from toad.widgets.project_panel import NO_PROJECT_VIEW @@ -16,6 +28,44 @@ from toad.screens.main import MainScreen +class SidebarThreadIdentity(ThreadReferenceReader[SessionIdentity]): + """A retired peer's old name must never address a replacement thread.""" + + def __init__(self, root): + self.root = root + + def current(self, reference): + return ThreadIdentity(reference.incarnation, self.root) + + def retired(self, reference): + return LocalThreadIdentity(f"{reference.name} (gone)") + + +class ConversationThreadSidebar(ThreadIdentityScope, LazyThreadSidebar): + """A DM's original declared panels describe its peer; channels supply no thread.""" + + def __init__(self, screen, threads): + self.threads = threads + self._owner = ref(screen) + super().__init__(state=screen._thread_sidebar_state) + + @property + def sidebar_thread_identity(self): + screen = self._owner() + if screen is None: + raise ReferenceError("The conversation sidebar owner has retired") + references = screen.app.coordination_access.observation.observed().sidebar.observation.thread_names() + return next((references.thread(thread).read(SidebarThreadIdentity(screen.wire_root)) + for thread in self.threads), NoThreadIdentity()) + + def make_panels(self): + screen = self._owner() + if screen is None: + raise ReferenceError("The conversation sidebar owner has retired") + return (member().compose_panel(screen) for thread in self.threads + for member in SessionPanel.members_with(ThreadDetailsPanel)) + + class SessionThreadSidebar(LazyThreadSidebar, NativePresentation): """Keep warm panels until eviction releases their widgets.""" diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 740ac7cbf..147b49736 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -3,6 +3,7 @@ from functools import cached_property +from agent_comms.cli_support.cli_failures import CLI_FAILURES from textual._callback import invoke from metaclass_registry.mro_dispatch import handles @@ -1308,10 +1309,11 @@ class PanelPicker(OneRow, FillWidth, widgets.Select): class PanelMenu(FillHeight, containers.Vertical): """Discover once from the owner; mount each original panel only when selected.""" - def __init__(self, entries, navigation: SidebarState): + def __init__(self, entries, navigation: SidebarState, scope: ViewSettingsScope): super().__init__() self.entries = tuple(entries) self.navigation = navigation + self.scope = scope selected = next((entry for entry in self.entries if entry.widget.panel_identity == navigation.panel_identity), self.entries[0]) self.picker = PanelPicker(((entry.title, entry) for entry in self.entries), @@ -1326,6 +1328,21 @@ def compose(self): @on(events.Mount) async def open_selection(self): await self.show_panel(self.picker.value) + self.run_worker(self.load_configuration(), group="configuration-panels", exclusive=True) + + async def load_configuration(self): + try: + entries = await self.scope.configuration_action_panels() + except CLI_FAILURES as error: + self.app.show_error(error, "Object settings") + return + if not self.is_attached: + return + selected = self.picker.value + self.entries += tuple(entries) + with self.prevent(widgets.Select.Changed): + self.picker.set_options((entry.title, entry) for entry in self.entries) + self.picker.value = selected @on(widgets.Select.Changed) async def selection_changed(self, event): @@ -1359,11 +1376,11 @@ class ThreadSidebar(SideBar): placement_type = ThreadSidebarPlacement def panel_entries(self): - yield from self.query_ancestor(ViewSettingsScope).configuration_panels yield from super().panel_entries() + yield from self.query_ancestor(ViewSettingsScope).configuration_panels def _compose_panels(self): - yield PanelMenu(self.panel_entries(), self.navigation) + yield PanelMenu(self.panel_entries(), self.navigation, self.query_ancestor(ViewSettingsScope)) def focus_contents(self): self.query_one(PanelPicker).focus() diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 6e6e974d4..cd6bcbb5f 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -93,7 +93,7 @@ def update_groups(self, groups): async def order_writer(self): tree = self.query_ancestor(SidebarPanelHost).query_one(ThreadCommsSidebar) - identity, generation = tree.thread_view.thread_identity, tree._generation + identity, generation = tree.thread_identity, tree._generation snapshot = tree._snapshot if snapshot is None: raise ValueError("Relationship ordering is not available yet") @@ -103,7 +103,7 @@ async def write(order: type[ThreadSort]): service = await tree.relationship_source(identity) if ( generation != tree._generation - or identity != tree.thread_view.thread_identity + or identity != tree.thread_identity ): return try: @@ -114,7 +114,7 @@ async def write(order: type[ThreadSort]): finally: if ( generation == tree._generation - and identity == tree.thread_view.thread_identity + and identity == tree.thread_identity ): tree.refresh_relationships(force=True) @@ -307,7 +307,7 @@ def __init__( @property def view_state(self): return self._states.setdefault( - self.thread_view.thread_identity, RelationshipTreeState() + self.thread_identity, RelationshipTreeState() ) async def relationship_source(self, identity: ThreadIdentity) -> Observing: @@ -317,7 +317,7 @@ async def relationship_source(self, identity: ThreadIdentity) -> Observing: def compose(self): yield RelationshipContext( - self.thread_view.thread_identity.display_thread(), + self.thread_identity.display_thread(), markup=False, ) self._filter_controls.clear() @@ -374,7 +374,7 @@ def _reset_relationships(self) -> None: group.display = False def accepts_observation(self) -> bool: - identity = self.thread_view.thread_identity + identity = self.thread_identity return self.panel_visible and self.model_state.serves(identity.root_path) def sync(self, *, change: SidebarChange, reorder: bool = False) -> None: @@ -447,7 +447,7 @@ def refresh_relationships(self, *, force=False): ) async def _refresh(self, generation): - await self.thread_view.thread_identity.inspect_owner( + await self.thread_identity.inspect_owner( lambda identity: self._refresh_owner(identity, generation), lambda text: self.query_one(RelationshipContext).update( Content(text), layout=False @@ -550,10 +550,10 @@ def row_for(self, selection): @property def navigation_root(self): - return self.thread_view.thread_identity.root_path + return self.thread_identity.root_path def open_target(self, target: NavigationTarget): - identity = self.thread_view.thread_identity + identity = self.thread_identity if ( identity.root_path != self.app.coordination_access.observation.observed().root @@ -577,7 +577,7 @@ def request_navigation(self, row, *, entry=None, generation=None): async def _navigate_current(self, group_key, expected, generation): if generation != self._generation or not expected.available: return - identity = self.thread_view.thread_identity + identity = self.thread_identity service = await self.relationship_source(identity) snapshot = await asyncio.to_thread(service.relationships, identity.thread_name()) if ( diff --git a/src/toad/widgets/thread_identity.py b/src/toad/widgets/thread_identity.py index a4f492804..28e95216e 100644 --- a/src/toad/widgets/thread_identity.py +++ b/src/toad/widgets/thread_identity.py @@ -8,7 +8,7 @@ from toad.core.events import ThreadIdentityChanged from toad.core_event_carrier import CoreEventReceiver from toad.session_scope import SessionScope -from toad.thread_identity import SessionIdentity +from toad.thread_identity import SessionIdentity, ThreadIdentityScope from textual.events import Mount from textual import on @@ -18,15 +18,19 @@ class ShowsThreadIdentity(CoreEventReceiver, Widget): def thread_view(self) -> SessionScope: return self.query_ancestor(SessionScope) + @property + def thread_identity(self) -> SessionIdentity: + return self.query_ancestor(ThreadIdentityScope).sidebar_thread_identity + @on(Mount) def _on_mount(self) -> None: self.observe_core(self.app.events) - self.identity_changed(self.thread_view.thread_identity) + self.identity_changed(self.thread_identity) @handles(ThreadIdentityChanged) def thread_identity_changed(self, event: ThreadIdentityChanged) -> None: if event.view is self.thread_view: - self.identity_changed(event.view.thread_identity) + self.identity_changed(self.thread_identity) @abstractmethod def identity_changed(self, identity: SessionIdentity) -> None: diff --git a/tests/textual/test_sidebar_projection.py b/tests/textual/test_sidebar_projection.py index 746f5cf27..5e57ef429 100644 --- a/tests/textual/test_sidebar_projection.py +++ b/tests/textual/test_sidebar_projection.py @@ -42,11 +42,22 @@ async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspa await ui.until(lambda: menu.navigation.panel_identity == ui_entry.widget.panel_identity, why='returning to settings') app.saved_settings.set(SETTINGS.ui.column_width, 83) assert state.fields.ui.column_width == 83 + width = next(part for part in menu.query(ObjectValueView) + if part.field.path.value == 'ui.column_width') + await ui.until(lambda: width.editor.placeholder == '83', why='inherited width preview') + assert width.editor.value == '' and width.editor.previewing + width.editor.value = '83' + width.editor.finish() + assert state.parameters['ui.column_width'] == 83 + assert not width.editor.previewing state.field('ui.column_width').edit(91) app.saved_settings.set(SETTINGS.ui.column_width, 85) assert state.fields.ui.column_width == 91 - state.reset_parameter('ui.column_width') + await width.reset.act() + assert state.parameters['ui.column_width'] is None assert state.fields.ui.column_width == 85 + await ui.until(lambda: width.editor.placeholder == '85', why='reset restores lazy preview') + assert width.editor.value == '' and width.editor.previewing file_mode = await ui.open_preview(preview) file_view = app.workspace_sessions.require(file_mode) file_bar = await ui.revealed(file_view.thread_sidebar) @@ -74,7 +85,7 @@ async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspa await ui.until(lambda: bool(notifications.widget.query(ObjectValueView)), why='global field controls') field_view = next(part for part in notifications.widget.query(ObjectValueView) if part.field.path.value == 'notifications.enable_sounds') - assert not field_view.inherit.display + assert str(field_view.reset.label) == 'Reset' assert 'Global setting' in str(field_view.source.render()) await ui.select_tab(channel) assert view.thread_sidebar is bar and bar.query_one(PanelMenu) is menu @@ -88,7 +99,10 @@ async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspa await ui.step() await ui.select_tab(original) native = app.workspace_sessions.require(original) - workspace.thread('panel-owner') + peer = workspace.thread('panel-owner') + from agent_comms.team.pi_rule_gate import PiRedeal + deal = PiRedeal.of(workspace.comms, peer) + deal.start() await workspace.bind(native, 'panel-owner') native_bar = await ui.revealed(native.thread_sidebar) native_menu = native_bar.query_one(PanelMenu) @@ -122,6 +136,39 @@ async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspa await ui.until(lambda: relationships.widget.is_mounted, why='the native relationship panel') assert relationships.widget.panel_visible relationships.widget._visible() + from toad.widgets.session_thread_panels import SessionPanel + assert set(SessionPanel.members_with(SessionPanel)) == { + type(owner) for owner in native_bar._panel_owners} + from agent_comms.cli_verbs.configure_rule_gate import ThreadRuleGateCliCommand + from toad.configured_view import ActionProperties + await ui.until(lambda: any(isinstance(entry.widget, ActionProperties) + and entry.widget.definition.declaration is ThreadRuleGateCliCommand + for entry in native_menu.entries), why='original rule-gate configuration') + gate_entry = next(entry for entry in native_menu.entries + if isinstance(entry.widget, ActionProperties) + and entry.widget.definition.declaration is ThreadRuleGateCliCommand) + native_menu.picker.value = gate_entry + await ui.until(lambda: gate_entry.widget.is_mounted and bool(gate_entry.widget.form.values), + why='original backend rule-gate form') + from agent_comms.team.rule_deal import RuleGateSettings + from dataclasses import fields + assert set(gate_entry.widget.form.values) == {field.name for field in fields(RuleGateSettings)} + from toad.widgets.comms_command_dialog import CommandPreparation + await ui.until(lambda: not CommandPreparation.running_on(gate_entry.widget), why='gate choices loaded') + gate_entry.widget.form.values['read_batch'].editor.value = '7' + await gate_entry.widget.confirm() + await ui.until(lambda: deal.current().read_batch == 7, why='original gate writer saves settings') + dm = await ui.open_direct('panel-owner', actor=workspace.viewer) + dm_view = app.workspace_sessions.require(dm) + dm_bar = await ui.revealed(dm_view.thread_sidebar) + dm_menu = dm_bar.query_one(PanelMenu) + dm_relationships = next(entry for entry in dm_menu.entries + if isinstance(entry.widget, ThreadCommsSidebar)) + dm_menu.picker.value = dm_relationships + await ui.until(lambda: dm_relationships.widget.is_mounted, why='DM original relationship panel') + assert dm_relationships.widget.thread_identity.thread_incarnation() == peer.incarnation + assert dm_view.thread_identity.thread_name() == workspace.viewer + await ui.select_tab(original) settings_entry = next(entry for entry in native_menu.entries if isinstance(entry.widget, ObjectProperties)) native_menu.picker.value = settings_entry From 5929421fc3cd872b1ec3841f46327a66ffed4a1d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 13 Nov 2026 02:25:27 -0500 Subject: [PATCH 2/2] Preserve command decoding and thread birth in embedded settings forms --- .../viewed-object-repair/delivery.md | 24 +++++ .../editor-boundary.plan.json | 20 +++++ .../editor-imports.plan.json | 18 ++++ .../viewed-object-repair/target.plan.json | 88 +++++++++++++++++++ .../viewed-object-repair/target_author.py | 57 ++++++++++++ src/toad/forms/editors.py | 2 +- src/toad/navigation_target.py | 23 +++-- src/toad/thread_identity.py | 8 ++ src/toad/widgets/comms_command_dialog.py | 7 +- tests/textual/test_sidebar_projection.py | 8 +- 10 files changed, 240 insertions(+), 15 deletions(-) create mode 100644 .verification/viewed-object-repair/delivery.md create mode 100644 .verification/viewed-object-repair/editor-boundary.plan.json create mode 100644 .verification/viewed-object-repair/editor-imports.plan.json create mode 100644 .verification/viewed-object-repair/target.plan.json create mode 100644 .verification/viewed-object-repair/target_author.py diff --git a/.verification/viewed-object-repair/delivery.md b/.verification/viewed-object-repair/delivery.md new file mode 100644 index 000000000..50bbe224b --- /dev/null +++ b/.verification/viewed-object-repair/delivery.md @@ -0,0 +1,24 @@ +# P219 source checkpoint + +Original viewed-object consumer passed 1/1 in 13.50s, exit 0: +/home/ts/.cache/agent-scratch/codex-latency/viewed-object-repair/consumer-4.log + +It checks lazy inherited placeholder text, typing the same inherited value as an explicit override, upstream changes preserving the override, Reset returning to raw None and the current inherited preview, global-field saves, separate channel/file identities, shared context editor state, actual changing ACP advertisements, every original native SessionPanel member, a real rule-gate form and its original backend write, DM peer birth versus actor identity, and hidden-panel observation lifecycle. + +## Applied owners + +- ConfigurationCommand membership stays on the existing Core configuration commands. ConfigureCliCommand now uses the original named target/action contracts and its unchanged writer; ThreadConfiguredResult uses the target-edit result contract. +- CommandForm owns preparation and submission in modal and sidebar presentations. Both use ThreadAction.submit. Native constraints stay on the original command decoder rather than being copied onto the encoded text draft. +- ThreadDetailsPanel shares the existing diagnostics/relationship declarations with DM views. ThreadIdentityScope keeps the panel subject separate from the sender; current/retired references retain peer identity. Native context/project/plan owners remain intact. +- ValueEditor derives preview styling from original raw/resolved values; no override flag or second resolved store. Reset uses ObjectState.reset_parameter on the original writer. The Override here switch is deleted. + +## Red history retained + +consumer.log: helper could not move fds above the shell's 1024 limit; no UI proof. +consumer-2.log: preview style used an invalid empty flag; newly declared peer then missed by a registry read after the app observer started. Store failure reported to claude-laptop as message b65fcca1edb0. The peer fixture is now declared before opening the app. +consumer-3.log: numeric native constraints were incorrectly applied to editor text; rule-gate write refused. Fixed at the shared encoded-editor boundary, preserving original command decoding/validation. +consumer-4.log: passed. + +Cold Toad/Core import and MRO passed; original panel family lists all five members and its two shared thread-details members. Native plans and apply logs retained. Existing unrelated guarded annotations remain unchanged; the touched lazy CommandDialog import was promoted to a module import with cold import proof. + +No installed or physical-success claim at this checkpoint. No total CPU claim. No subagent, profiling, broad suite or provider request. diff --git a/.verification/viewed-object-repair/editor-boundary.plan.json b/.verification/viewed-object-repair/editor-boundary.plan.json new file mode 100644 index 000000000..ef860a3f2 --- /dev/null +++ b/.verification/viewed-object-repair/editor-boundary.plan.json @@ -0,0 +1,20 @@ +{ + "recipes": [ + { + "recipe_id": "keep-native-value-constraints-on-the-original-command-decoder", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "target_qualname": "CommandForm.__init__", + "replacements": [ + { + "old_source": " if get_origin(parameter.annotation) is Annotated:\n hints.extend(item for item in get_args(parameter.annotation)[1:]\n if not isinstance(item, AnnotationChoices))\n", + "new_source": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/editor-imports.plan.json b/.verification/viewed-object-repair/editor-imports.plan.json new file mode 100644 index 000000000..dc82b08f4 --- /dev/null +++ b/.verification/viewed-object-repair/editor-imports.plan.json @@ -0,0 +1,18 @@ +{ + "recipes": [ + { + "recipe_id": "remove-obsolete-editor-value-metadata-imports", + "operations": [ + { + "operation": "remove_import_bindings", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/widgets/comms_command_dialog.py", + "bound_names": [ + "get_args", + "get_origin", + "AnnotationChoices" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/target.plan.json b/.verification/viewed-object-repair/target.plan.json new file mode 100644 index 000000000..ff704f127 --- /dev/null +++ b/.verification/viewed-object-repair/target.plan.json @@ -0,0 +1,88 @@ +{ + "recipes": [ + { + "recipe_id": "preserve-subject-birth-and-local-session-ownership", + "operations": [ + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/forms/editors.py", + "target_qualname": "ValueEditor.paint_value", + "replacements": [ + { + "old_source": "self.styles.text_style = \"dim\" if self.previewing else \"\"", + "new_source": "self.styles.text_style = \"dim\" if self.previewing else None" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_identity.py", + "import_source": "from agent_comms.messaging.channel_targets import IncarnationThreadTarget" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_identity.py", + "target_qualname": "SessionIdentity.command_target", + "source": " def configuration_targets(self):\n \"\"\"A local or absent session is configured by its own advertisements.\"\"\"\n return ()\n\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/thread_identity.py", + "target_qualname": "ThreadIdentity.thread_incarnation", + "source": " def configuration_targets(self):\n return (IncarnationThreadTarget(self.owner),)\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "ConfigurationTarget.configuration_contexts", + "replacements": [ + { + "old_source": " navigation = view.navigation_context\n observed = view.app.coordination_access.observing(view.coordination_root)\n return (TargetContext(view.app, observed, CommandTarget.named(self.name),\n navigation.actor, navigation.project_path,\n navigation.owner_mode),)", + "new_source": " targets = self.configuration_subjects(view)\n return tuple(TargetContext(view.app,\n view.app.coordination_access.observing(view.coordination_root),\n subject, view.navigation_context.actor,\n view.navigation_context.project_path,\n view.navigation_context.owner_mode)\n for subject in targets)" + } + ] + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "ConfigurationTarget.configuration_contexts", + "source": " @abstractmethod\n def configuration_subjects(self, view): ...\n\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "ThreadTarget.open", + "source": " def configuration_subjects(self, view):\n return view.thread_identity.configuration_targets()\n\n" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/navigation_target.py", + "target_qualname": "HistoryTarget.for_name", + "source": " def configuration_subjects(self, view):\n return (view.conversation.retained_target(),)\n\n" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " preview.write_text('A viewed file\\n')\n async with", + "new_source": " preview.write_text('A viewed file\\n')\n peer = workspace.thread('panel-owner')\n from agent_comms.team.pi_rule_gate import PiRedeal\n deal = PiRedeal.of(workspace.comms, peer)\n deal.start()\n async with" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/tests/textual/test_sidebar_projection.py", + "target_qualname": "test_view_menu_discovers_settings_and_keeps_each_objects_state", + "replacements": [ + { + "old_source": " peer = workspace.thread('panel-owner')\n from agent_comms.team.pi_rule_gate import PiRedeal\n deal = PiRedeal.of(workspace.comms, peer)\n deal.start()\n", + "new_source": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.verification/viewed-object-repair/target_author.py b/.verification/viewed-object-repair/target_author.py new file mode 100644 index 000000000..38f668cf1 --- /dev/null +++ b/.verification/viewed-object-repair/target_author.py @@ -0,0 +1,57 @@ +import json +from pathlib import Path +r=Path.cwd();ops=[] +def p(f,t,a,b):ops.append(dict(operation='patch_target',file_path=str(r/f),target_qualname=t,replacements=[dict(old_source=a,new_source=b)])) +def ins(f,t,s):ops.append(dict(operation='insert_before_target',file_path=str(r/f),target_qualname=t,source=s)) +def i(f,s):ops.append(dict(operation='ensure_import',file_path=str(r/f),import_source=s)) +f='src/toad/forms/editors.py' +p(f,'ValueEditor.paint_value','self.styles.text_style = "dim" if self.previewing else ""','self.styles.text_style = "dim" if self.previewing else None') +f='src/toad/thread_identity.py' +i(f,'from agent_comms.messaging.channel_targets import IncarnationThreadTarget') +ins(f,'SessionIdentity.command_target',''' def configuration_targets(self): + """A local or absent session is configured by its own advertisements.""" + return () + +''') +ins(f,'ThreadIdentity.thread_incarnation',''' def configuration_targets(self): + return (IncarnationThreadTarget(self.owner),) + +''') +f='src/toad/navigation_target.py' +p(f,'ConfigurationTarget.configuration_contexts',''' navigation = view.navigation_context + observed = view.app.coordination_access.observing(view.coordination_root) + return (TargetContext(view.app, observed, CommandTarget.named(self.name), + navigation.actor, navigation.project_path, + navigation.owner_mode),)''',''' targets = self.configuration_subjects(view) + return tuple(TargetContext(view.app, + view.app.coordination_access.observing(view.coordination_root), + subject, view.navigation_context.actor, + view.navigation_context.project_path, + view.navigation_context.owner_mode) + for subject in targets)''') +ins(f,'ConfigurationTarget.configuration_contexts',''' @abstractmethod + def configuration_subjects(self, view): ... + +''') +ins(f,'ThreadTarget.open',''' def configuration_subjects(self, view): + return view.thread_identity.configuration_targets() + +''') +ins(f,'HistoryTarget.for_name',''' def configuration_subjects(self, view): + return (view.conversation.retained_target(),) + +''') +f='tests/textual/test_sidebar_projection.py' +p(f,'test_view_menu_discovers_settings_and_keeps_each_objects_state',""" preview.write_text('A viewed file\\n') + async with""",""" preview.write_text('A viewed file\\n') + peer = workspace.thread('panel-owner') + from agent_comms.team.pi_rule_gate import PiRedeal + deal = PiRedeal.of(workspace.comms, peer) + deal.start() + async with""") +p(f,'test_view_menu_discovers_settings_and_keeps_each_objects_state',""" peer = workspace.thread('panel-owner') + from agent_comms.team.pi_rule_gate import PiRedeal + deal = PiRedeal.of(workspace.comms, peer) + deal.start() +""",'') +Path('.verification/viewed-object-repair/target.plan.json').write_text(json.dumps({'recipes':[{'recipe_id':'preserve-subject-birth-and-local-session-ownership','operations':ops}]},indent=2)) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index f9f83b389..c87e549e4 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -81,7 +81,7 @@ def previewing(self) -> bool: def paint_value(self) -> None: self.paint() self.styles.color = self.app.colours.colour(MutedColour) if self.previewing else None - self.styles.text_style = "dim" if self.previewing else "" + self.styles.text_style = "dim" if self.previewing else None def control_mounted(self, event: events.Mount) -> None: self.call_after_refresh(self.paint_value) diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index e1d9bf1ae..c62a9cca3 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -99,15 +99,24 @@ async def open(self, context: NavigationContext) -> str: class ConfigurationTarget: """Named thread and history destinations borrow Core's configuration membership.""" + @abstractmethod + def configuration_subjects(self, view): ... + def configuration_contexts(self, view): - navigation = view.navigation_context - observed = view.app.coordination_access.observing(view.coordination_root) - return (TargetContext(view.app, observed, CommandTarget.named(self.name), - navigation.actor, navigation.project_path, - navigation.owner_mode),) + targets = self.configuration_subjects(view) + return tuple(TargetContext(view.app, + view.app.coordination_access.observing(view.coordination_root), + subject, view.navigation_context.actor, + view.navigation_context.project_path, + view.navigation_context.owner_mode) + for subject in targets) class ThreadTarget(ConfigurationTarget, NavigationTarget): + + def configuration_subjects(self, view): + return view.thread_identity.configuration_targets() + async def open(self, context: NavigationContext) -> str: return await context.app.thread_navigation.open( owner_mode=context.owner_mode, project_path=context.project_path, target=self, @@ -141,6 +150,10 @@ class HistoryRoute: def for_name(cls, name: str) -> HistoryTarget: ... class HistoryTarget(ConfigurationTarget, HistoryRoute, NavigationTarget): + + def configuration_subjects(self, view): + return (view.conversation.retained_target(),) + @classmethod def for_name(cls, name: str) -> HistoryTarget: return cls(name) diff --git a/src/toad/thread_identity.py b/src/toad/thread_identity.py index c19c1fe74..9ebeeae4e 100644 --- a/src/toad/thread_identity.py +++ b/src/toad/thread_identity.py @@ -9,6 +9,7 @@ from pathlib import Path from agent_comms.errors import UnregisteredThreadError +from agent_comms.messaging.channel_targets import IncarnationThreadTarget from agent_comms.registry.thread_identity import ( ThreadIncarnation, UnrecordedThreadIncarnation, @@ -102,6 +103,10 @@ def resolve_thread(self, observed: Observing, project: Path) -> str: """An unbound view asks Core to discover its project's session.""" return observed.session_thread(project, self.thread_name()).name_or(self.thread_name()) + def configuration_targets(self): + """A local or absent session is configured by its own advertisements.""" + return () + def command_target[T](self, target: Callable[[str], T]) -> T: """A named session addresses backend commands as its thread.""" return target(self.thread_name()) @@ -147,6 +152,9 @@ class ThreadIdentity(NamedThreadIdentity): def thread_name(self) -> str: return self.owner.name + def configuration_targets(self): + return (IncarnationThreadTarget(self.owner),) + def thread_incarnation(self) -> ThreadIncarnation: return self.owner diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 3e91a077b..2f3961f98 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -4,7 +4,7 @@ import asyncio from abc import abstractmethod from dataclasses import dataclass, field, make_dataclass -from typing import Annotated, get_args, get_origin +from typing import Annotated from agent_comms.cli_support.cli_failures import CLI_FAILURES from agent_comms.cli_verbs.cli_command import PreparedTargetAction, TargetAction @@ -18,7 +18,7 @@ from agent_comms.transport.tools.sidebar import CommsPrepareTargetActionTool from formbridge.forms.declared_fields import DeclaredMetadata, Multiline from objectstate import ObjectState -from python_introspect import AnnotationChoices, FieldLabel +from python_introspect import FieldLabel from python_introspect.choices import LabelledChoices from textual import on, work from textual.app import ComposeResult @@ -228,9 +228,6 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi declarations = [] for parameter in fields: hints = [FieldLabel(parameter.label), parameter.description] - if get_origin(parameter.annotation) is Annotated: - hints.extend(item for item in get_args(parameter.annotation)[1:] - if not isinstance(item, AnnotationChoices)) hints.extend(parameter.editor_hints( lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) if parameter.multiline: diff --git a/tests/textual/test_sidebar_projection.py b/tests/textual/test_sidebar_projection.py index 5e57ef429..e82df5ed2 100644 --- a/tests/textual/test_sidebar_projection.py +++ b/tests/textual/test_sidebar_projection.py @@ -19,6 +19,10 @@ async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspa workspace.comms.channels.create_tag('settings') preview = workspace.project / 'example.txt' preview.write_text('A viewed file\n') + peer = workspace.thread('panel-owner') + from agent_comms.team.pi_rule_gate import PiRedeal + deal = PiRedeal.of(workspace.comms, peer) + deal.start() async with workspace.app(size=(140, 45)) as ui: app, pilot = ui.textual original = ui.selected_tab @@ -99,10 +103,6 @@ async def test_view_menu_discovers_settings_and_keeps_each_objects_state(workspa await ui.step() await ui.select_tab(original) native = app.workspace_sessions.require(original) - peer = workspace.thread('panel-owner') - from agent_comms.team.pi_rule_gate import PiRedeal - deal = PiRedeal.of(workspace.comms, peer) - deal.start() await workspace.bind(native, 'panel-owner') native_bar = await ui.revealed(native.thread_sidebar) native_menu = native_bar.query_one(PanelMenu)