diff --git a/.verification/p192-viewed-object-configuration/delivery.txt b/.verification/p192-viewed-object-configuration/delivery.txt new file mode 100644 index 000000000..2e6711466 --- /dev/null +++ b/.verification/p192-viewed-object-configuration/delivery.txt @@ -0,0 +1,43 @@ +P192 — viewed-object properties + +Toad PR: https://github.com/OpenHCSDev/toad/pull/1750 +ObjectState PR: https://github.com/OpenHCSDev/ObjectState/pull/66 +ObjectState merged: 226fcd8918e16f6d1b594cac0bdcf8057aab832a + +The same ObjectConfiguration edits the properties sidebar and context form. +The settings declarations own field scope; ordinary files inherit view settings, +conversations inherit those and their composer/agent display fields. The actual +TabIdentity is saved beside raw explicit overrides. None means inherit; choosing +Override here pins even an equal value, and Use global default resumes inheritance. +The original global ObjectState supplies values and source information. A duplicate +open identity or a forbidden override refuses rather than introducing another answer. +Local display configuration stays local; remote agent and rule writes remain Core's. + +ObjectState previously stopped at direct dataclass children. The shared extraction +now traverses each declared stored object once, retains shared instances and refuses +conflicting instances of one nominal configuration type. No parallel resolver. + +Closed property sidebars inherit LazyThreadSidebar; the native session sidebar uses +that same lifecycle instead of a copy. Editors paint after native mount. Global and +object field-view families remain separate through their declared family owner. +All contextual action menus carry actual operations; the mirrored string/action +maps and show_target_menu are removed. Link menus keep their original admission. + +Evidence: +- p192-object-control.txt: passed live nested defaults, explicit-equal override, + same-state sidebar/context edits, reset, source, persistence, and scope filtering. +- p192-objectstate-controls.txt: 14 passed, exit 0, including nested/shared identity + and collision controls. +- p192-view-lifecycle.txt: original thread/channel/file navigation and close case, + 1 passed, exit 0. Initial launch failed before behavior because the inherited + descriptor limit was 1024 while the existing helper requests descriptors >=1024; + the successful launch used soft limit 8192, below hard limit 524288. +- App, MainScreen, FilePreviewScreen and the shared forms import on the new sources. + +The source check is exactly class ObjectConfiguration(, not the dialog's name. +The original order's created-owner citation named the new file's first line; the +actual state class is in object_configuration.py. The eliminated file-menu identity +copy is now owned by the selected ActionMenu row, not by a second callable map. + +No paid provider command or profile was run. No install or physical desktop trial +is claimed by this source report. Auth recovery is independently held by codex-parallel. diff --git a/.verification/p192-viewed-object-configuration/p192-author-action-menus.py b/.verification/p192-viewed-object-configuration/p192-author-action-menus.py new file mode 100644 index 000000000..0ca27419b --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-author-action-menus.py @@ -0,0 +1,61 @@ +import ast,json +from pathlib import Path +r=Path('/home/ts/wt/codex-live--P181--toad/src/toad');ops=[] +def patch(f,t,o,n):ops.append(dict(operation='patch_target',file_path=str(r/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def imp(f,s):ops.append(dict(operation='ensure_import',file_path=str(r/f),import_source=s)) +s=(r/'widgets/comms_menu.py').read_text();n=next(n for n in ast.parse(s).body if isinstance(n,ast.FunctionDef) and n.name=='show_target_menu') +patch('widgets/comms_menu.py','show_target_menu',ast.get_source_segment(s,n),'''class ActionMenu(ContextMenu[Callable[[], object]]): + """The selected row carries the actual operation; there is no second lookup table.""" + + @classmethod + def show(cls, screen, anchor: Region, title: str, items): + return super().show(screen, anchor, title, items, callback=cls.run_action) + + @staticmethod + async def run_action(action): + if action is not None: + await invoke(action) +''') +imp('widgets/comms_menu.py','from textual._callback import invoke') +patch('target_commands.py','TargetContext.show_menu','from toad.widgets.comms_menu import show_target_menu','from toad.widgets.comms_menu import ActionMenu') +patch('target_commands.py','TargetContext.show_menu',''' show_target_menu(selected_screen, Region.from_offset(offset, (0, 0)), self.title, + [(command.command.removeprefix('/'), command.label(self)) for command in choices], + {command.command.removeprefix('/'): partial(self.execute_menu, sidebar, command) + for command in choices})''',''' ActionMenu.show(selected_screen, Region.from_offset(offset, (0, 0)), self.title, + [(partial(self.execute_menu, sidebar, command), command.label(self)) + for command in choices])''') +patch('widgets/thread_comms.py','toad.widgets.thread_comms','from toad.widgets.comms_menu import show_target_menu','from toad.widgets.comms_menu import ActionMenu') +patch('widgets/thread_comms.py','ThreadCommsSidebar.show_row_menu',''' items = [("open", "Open")] if row.available else [] + items.append(("copy", "Copy name")) + show_target_menu( + self.screen, + Region.from_offset(offset, (0, 0)), + target, + items, + {"open": open_selected, "copy": lambda: self.app.copy_to_clipboard(target)}, + )''',''' items = [(open_selected, "Open")] if row.available else [] + items.append((lambda: self.app.copy_to_clipboard(target), "Copy name")) + ActionMenu.show(self.screen, Region.from_offset(offset, (0, 0)), target, items)''') +patch('widgets/context_explorer.py','ContextCorrect.apply','from toad.widgets.comms_menu import show_target_menu','from toad.widgets.comms_menu import ActionMenu') +patch('widgets/context_explorer.py','ContextCorrect.apply',''' show_target_menu( + self.screen, + self.region, + "Correct original answer", + [(answer.declared_name, answer.public_title()) for answer in answers], + {answer.declared_name: partial(self._correct, explorer, model, answer) for answer in answers}, + )''',''' ActionMenu.show( + self.screen, self.region, "Correct original answer", + [(partial(self._correct, explorer, model, answer), answer.public_title()) + for answer in answers], + )''') +patch('configured_view.py','toad.configured_view','from toad.widgets.comms_menu import ContextMenu','from toad.widgets.comms_menu import ActionMenu') +patch('configured_view.py','ConfiguredView.configuration_menu',''' ContextMenu.show(self.screen, Region.from_offset(event.screen_offset, (0, 0)), + self.display_name, [(self.open_configuration, "Properties…")], + callback=lambda chosen: chosen() if chosen is not None else None)''',''' ActionMenu.show(self.screen, Region.from_offset(event.screen_offset, (0, 0)), + self.display_name, [(self.open_configuration, "Properties…")])''') +patch('project_link.py','ProjectFileLink.copy_file','from toad.widgets.comms_menu import ContextMenu','from toad.widgets.comms_menu import ActionMenu') +patch('project_link.py','ProjectFileLink.copy_file',''' ContextMenu.show(widget.screen, Region.from_offset(event.screen_offset, (0, 0)), full_path, + [(copy_path, "Copy full path")], + callback=lambda chosen: chosen() if chosen is not None else None)''',''' ActionMenu.show(widget.screen, Region.from_offset(event.screen_offset, (0, 0)), full_path, + [(copy_path, "Copy full path")])''') +Path('/home/ts/.cache/agent-scratch/codex-latency/p192-action-menus.plan.json').write_text(json.dumps({'recipes':[dict(recipe_id='menu-rows-own-their-actual-operation',operations=ops)]},indent=2)) diff --git a/.verification/p192-viewed-object-configuration/p192-author-assembly.py b/.verification/p192-viewed-object-configuration/p192-author-assembly.py new file mode 100644 index 000000000..9dd99fea7 --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-author-assembly.py @@ -0,0 +1,42 @@ +import ast,json +from pathlib import Path +root=Path('/home/ts/wt/codex-live--P181--toad/src/toad');ops=[] +def patch(file,target,old,new):ops.append(dict(operation='patch_target',file_path=str(root/file),target_qualname=target,replacements=[dict(old_source=old,new_source=new)])) +def imp(file,source):ops.append(dict(operation='ensure_import',file_path=str(root/file),import_source=source)) +patch('column_layout.py','toad.column_layout','from textual.scrollable_container import ScrollableContainer','from textual.containers import ScrollableContainer') +# The common native body owns mounting for every configured source. +ops.append(dict(operation='insert_after_target',file_path=str(root/'settings_scope.py'),target_qualname='ConversationSettingsScope',source='''class ObjectSidebarHost(Widget): + """The native body in which a viewed object's properties are placed.""" +''')) +imp('settings_scope.py','from textual.widget import Widget') +imp('screens/session_view.py','from toad.settings_scope import ObjectSidebarHost') +patch('screens/session_view.py','SessionBody','class SessionBody(WidgetView, ToadNode, Center):','class SessionBody(WidgetView, ToadNode, ObjectSidebarHost, Center):') +patch('screens/session_view.py','SidebarSessionView','def sidebars(self) -> tuple[SidebarT, ...]:','def sidebars(self) -> tuple[SideBar, ...]:') +imp('screens/file_preview.py','from toad.screens.session_view import SessionBody') +patch('screens/file_preview.py','FilePreviewScreen.compose',''' with FileContents(): + yield self.preview''',''' with SessionBody(): + with FileContents(): + yield self.preview''') +imp('configured_view.py','from toad.settings_scope import ObjectSidebarHost') +imp('configured_view.py','from toad.session_tracker import SidebarState') +patch('configured_view.py','ConfiguredView',''' return SideBar(SideBar.Panel("Properties", ObjectProperties.whole(self.configuration_state), flex=True), + placement_identity=ObjectSidebarPlacement, hide=True)''',''' return ObjectSidebar(SideBar.Panel("Properties", ObjectProperties.whole(self.configuration_state), flex=True), + state=SidebarState())''') +patch('configured_view.py','ConfiguredView',' @on(events.Unmount)',''' @on(events.Mount) + async def mount_configuration(self): + await self.query_one(ObjectSidebarHost).mount(self.configuration_sidebar) + + @on(events.Unmount)''') +imp('configured_view.py','from toad.widgets.side_bar import ThreadSidebar') +ops.append(dict(operation='insert_before_target',file_path=str(root/'configured_view.py'),target_qualname='ObjectProperties',source='''class ObjectSidebar(ThreadSidebar): + placement_type = ObjectSidebarPlacement + + +''')) +patch('widgets/side_bar.py','ThreadSidebar','class ThreadSidebar(SideBar):','class ThreadSidebar(SideBar):\n placement_type = ThreadSidebarPlacement\n') +patch('widgets/side_bar.py','ThreadSidebar','placement_identity=ThreadSidebarPlacement','placement_identity=self.placement_type') +# Remove the holes left by declaration relocation, keeping field source otherwise exact. +s=(root/'preferences.py').read_text();c=next(n for n in ast.parse(s).body if isinstance(n,ast.ClassDef) and n.name=='UiSettings');before=ast.get_source_segment(s,c);after=before +while '\n\n\n' in after:after=after.replace('\n\n\n','\n\n') +if after!=before:patch('preferences.py','UiSettings',before,after) +Path('/home/ts/.cache/agent-scratch/codex-latency/p192-assembly.plan.json').write_text(json.dumps({'recipes':[dict(recipe_id='native-sidebar-assembly-and-shared-placement',operations=ops)]},indent=2)+'\n') diff --git a/.verification/p192-viewed-object-configuration/p192-author-lazy-panels.py b/.verification/p192-viewed-object-configuration/p192-author-lazy-panels.py new file mode 100644 index 000000000..9ea0ce5fb --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-author-lazy-panels.py @@ -0,0 +1,61 @@ +import ast,json +from pathlib import Path +r=Path('/home/ts/wt/codex-live--P181--toad/src/toad');ops=[] +def patch(f,t,o,n):ops.append(dict(operation='patch_target',file_path=str(r/f),target_qualname=t,replacements=[dict(old_source=o,new_source=n)])) +def imp(f,s):ops.append(dict(operation='ensure_import',file_path=str(r/f),import_source=s)) +ops.append(dict(operation='insert_after_target',file_path=str(r/'widgets/side_bar.py'),target_qualname='ThreadSidebar',source='''class LazyThreadSidebar(ThreadSidebar): + """Create panel widgets only when this view's sidebar opens.""" + + def __init__(self, *panels, **kwargs): + super().__init__(*panels, defer_mount=True, **kwargs) + + @property + def wants_panels(self) -> bool: + return super().wants_panels and not self.collapsed + + @abstractmethod + def make_panels(self): ... + + def _compose_panels(self): + if not self.panels: + self.panels.extend(self.make_panels()) + yield from super()._compose_panels() +''')) +imp('widgets/side_bar.py','from abc import abstractmethod') +patch('widgets/session_thread_sidebar.py','SessionThreadSidebar','class SessionThreadSidebar(ThreadSidebar, NativePresentation):','class SessionThreadSidebar(LazyThreadSidebar, NativePresentation):') +patch('widgets/session_thread_sidebar.py','SessionThreadSidebar','super().__init__(state=screen._thread_sidebar_state, defer_mount=True)','super().__init__(state=screen._thread_sidebar_state)') +s=(r/'widgets/session_thread_sidebar.py').read_text();cls=next(n for n in ast.parse(s).body if isinstance(n,ast.ClassDef) and n.name=='SessionThreadSidebar');node=next(n for n in cls.body if isinstance(n,ast.FunctionDef) and n.name=='wants_panels');start=node.decorator_list[0].lineno;old='\n'.join(s.splitlines()[start-1:node.end_lineno]);patch('widgets/session_thread_sidebar.py','SessionThreadSidebar',old,'') +old=''' def _compose_panels(self) -> ComposeResult: + if not self.panels: + screen = self._owner() + if screen is None: + raise ReferenceError("The session sidebar owner has retired") + self.panels.extend(owner.compose_panel(screen) for owner in self._panel_owners) + yield from super()._compose_panels()''' +new=''' def make_panels(self): + screen = self._owner() + if screen is None: + raise ReferenceError("The session sidebar owner has retired") + return (owner.compose_panel(screen) for owner in self._panel_owners)''' +patch('widgets/session_thread_sidebar.py','SessionThreadSidebar',old,new) +imp('widgets/session_thread_sidebar.py','from toad.widgets.side_bar import LazyThreadSidebar') +patch('configured_view.py','ObjectSidebar','class ObjectSidebar(ThreadSidebar):\n placement_type = ObjectSidebarPlacement','''class ObjectSidebar(LazyThreadSidebar): + placement_type = ObjectSidebarPlacement + + def __init__(self, configuration): + self.configuration = configuration + super().__init__(state=SidebarState()) + + def make_panels(self): + return (SideBar.Panel("Properties", ObjectProperties.whole(self.configuration), flex=True),)''') +imp('configured_view.py','from toad.widgets.side_bar import LazyThreadSidebar') +patch('configured_view.py','ConfiguredView',''' return ObjectSidebar(SideBar.Panel("Properties", ObjectProperties.whole(self.configuration_state), flex=True), + state=SidebarState())''',''' return ObjectSidebar(self.configuration_state)''') +patch('forms/form_view.py','FormView.follow_state',''' self.state.on_state_changed(self.marks_changed) + self.marks_changed(self.state.dirty_fields)''',''' self.state.on_state_changed(self.marks_changed) + self.values_changed(set(self.values)) + self.marks_changed(self.state.dirty_fields)''') +# Avoid keeping the inherited parent name as a second unused import. +for file,names in [('configured_view.py',['ThreadSidebar']),('widgets/session_thread_sidebar.py',['ThreadSidebar','ComposeResult'])]: + ops.append(dict(operation='remove_import_bindings',file_path=str(r/file),bound_names=names)) +Path('/home/ts/.cache/agent-scratch/codex-latency/p192-lazy-panels.plan.json').write_text(json.dumps({'stages':[{'recipes':[dict(recipe_id='inherit-one-lazy-view-sidebar-lifecycle',operations=[op for op in ops if op['operation']!='remove_import_bindings'])]},{'recipes':[dict(recipe_id='remove-replaced-parent-imports',operations=[op for op in ops if op['operation']=='remove_import_bindings'])]}]},indent=2)) diff --git a/.verification/p192-viewed-object-configuration/p192-author-scopes.py b/.verification/p192-viewed-object-configuration/p192-author-scopes.py new file mode 100644 index 000000000..35f4134fb --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-author-scopes.py @@ -0,0 +1,83 @@ +import ast,json,textwrap +from pathlib import Path +root=Path('/home/ts/wt/codex-live--P181--toad/src/toad') +ops=[] +def patch(file,target,old,new): + ops.append({'operation':'patch_target','file_path':str(root/file),'target_qualname':target,'replacements':[{'old_source':old,'new_source':new}]}) +def imp(file,text):ops.append({'operation':'ensure_import','file_path':str(root/file),'import_source':text}) +scopes='''"""The configuration state owned by an application or one viewed object.""" +from abc import abstractmethod +from objectstate import ObjectState +from textual.dom import DOMNode + + +class SettingsReader(DOMNode): + @property + def configuration_state(self) -> ObjectState: + return self.query_ancestor(SettingsScope).configuration_state + + @property + def settings(self): + return self.configuration_state.to_resolved_object() + + +class SettingsScope(SettingsReader): + @property + @abstractmethod + def configuration_state(self) -> ObjectState: ... + + def settings_widgets(self, widget_type): + return self.walk_children(widget_type) + + +class ViewSettingsScope(SettingsScope): + """A file, thread or channel owns its display overrides.""" + + +class ConversationSettingsScope(ViewSettingsScope): + """A thread or channel additionally owns conversation and composer settings.""" +''' +ops.append({'operation':'create_file','file_path':str(root/'settings_scope.py'),'source':scopes}) +patch('setting_declarations.py','SettingsDeclaration',' """An actual settings record, recognized by ObjectState\'s lazy configuration factory."""',''' """An actual settings record and the scopes its own field declarations admit.""" + + configuration_scopes: ClassVar[tuple[type[SettingsScope], ...]] = () + + def __init_subclass__(cls, *, scopes: tuple[type[SettingsScope], ...] = (), **kwargs): + super().__init_subclass__(**kwargs) + cls.configuration_scopes = scopes + + @classmethod + def field_source(cls, name: str) -> type[SettingsDeclaration]: + return next(owner for owner in cls.__mro__ + if name in vars(owner).get("__annotations__", {})) + + @classmethod + def admits_field(cls, name: str, scope: SettingsScope) -> bool: + return any(isinstance(scope, admitted) + for admitted in cls.field_source(name).configuration_scopes)''') +imp('setting_declarations.py','from typing import ClassVar') +imp('setting_declarations.py','from toad.settings_scope import SettingsScope') +# Move each original field together with its help, keeping its source text intact. +p=root/'preferences.py';s=p.read_text();tree=ast.parse(s);ui=next(n for n in tree.body if isinstance(n,ast.ClassDef) and n.name=='UiSettings') +groups=[('ViewUiSettings','SettingsDeclaration','ViewSettingsScope',{'column','column_width','column_alignment','scrollbar'}),('ConversationUiSettings','ViewUiSettings','ConversationSettingsScope',{'input','info_bar','status_line','agent_title','history_buffer_viewports','prune_low_mark','prune_excess','throbber'})] +lines=s.splitlines(keepends=True) +for name,base,scope,names in groups: + chunks=[] + for i,node in enumerate(ui.body): + if not isinstance(node,ast.AnnAssign) or not isinstance(node.target,ast.Name) or node.target.id not in names:continue + end=node.end_lineno + if i+1 None: + self.identity, self.defaults, self.owner = identity, defaults, owner + self.path = directory / "objects" / f"{identity.mode_name}.json" + scope = str(ScopeKey.of("view", identity.mode_name)) + if ObjectStateRegistry.get_by_scope(scope) is not None: + raise ValueError(f"Settings already open for {identity}") + lazy_type = LazyDataclassFactory.make_lazy_simple(type(defaults.object_instance)) + super().__init__(lazy_type(), scope_id=scope, parent_state=defaults) + if self.path.exists(): + document = FieldCodec.decode(ObjectSettingsDocument, FieldCodec.parse(self.path.read_text("utf-8"))) + if document.identity != identity: + raise ValueError(f"Settings identity does not match {self.path}") + for path, encoded in document.values.items(): + if path not in self.editable_paths: + raise ValueError(f"{path} cannot be overridden for {identity}") + field = self.field(path) + field.edit(FieldCodec.decode(field.annotation, encoded)) + self.mark_saved() + ObjectStateRegistry.register(self) + self.on_resolved_changed(self.apply_changes) + self.on_state_changed(self.save_changes) + + def field(self, path: str) -> ObjectSettingField: + return ObjectSettingField(self, self.parameter_structure.path(path)) + + @cached_property + def editable_paths(self) -> frozenset[str]: + return frozenset(path.value for path in self.parameter_structure.leaf_paths() + if self.admits(path.value)) + + def admits(self, path: str) -> bool: + field = self.field(path) + owner = field.owner_type + return (issubclass(owner, SettingsDeclaration) + and owner.admits_field(field.path.field_name, self.owner)) + + def shows(self, path: str) -> bool: + return any(name == path or name.startswith(path + ".") for name in self.editable_paths) + + def apply_changes(self, paths: set[str]) -> None: + for path in paths & self.editable_paths: + field = self.field(path) + if (effect := field.metadata(Effect)) is not None: + effect.apply(self.owner, field) + + def save_changes(self, paths: set[str]) -> None: + if not paths: + return + values = {path: FieldCodec.encode(value, self.field(path).annotation) + for path, value in self.get_current_values().items() + if path in self.editable_paths and value is not None} + text = FieldCodec.dumps(ObjectSettingsDocument(self.identity, values)) + self.path.parent.mkdir(parents=True, exist_ok=True) + atomic.write(str(self.path), text) + self.mark_saved() + + def close(self) -> None: + self.off_resolved_changed(self.apply_changes) + self.off_state_changed(self.save_changes) + ObjectStateRegistry.unregister(self) +''') +# Scope reads follow the actual DOM owner; a detached widget starts from app defaults. +patch('settings_scope.py','SettingsReader',''' @property + def configuration_state(self) -> ObjectState: + return self.query_ancestor(SettingsScope).configuration_state''',''' @property + def configuration_scope(self) -> SettingsScope: + return next((node for node in self.walk_ancestors(with_self=True) + if isinstance(node, SettingsScope)), self.app) + + @property + def configuration_state(self) -> ObjectState: + return self.configuration_scope.configuration_state''') +# Compose the actual app default owner into the same settings scope lattice. +patch('textual_colours.py','ThemedApp','class ThemedApp(App[ReturnType], ResultShower, metaclass=_ThemedAppMeta):','class ThemedApp(SettingsScope, App[ReturnType], ResultShower, metaclass=_ThemedAppMeta):') +patch('textual_colours.py','ThemedApp',' self.saved_settings = self.open_settings()',''' self.saved_settings = self.open_settings() + ObjectStateRegistry.register(self.saved_settings.state)''') +patch('textual_colours.py','ThemedApp',' def open_settings(self) -> SavedSettings:',''' @property + def configuration_state(self) -> ObjectState: + return self.saved_settings.state + + def open_settings(self) -> SavedSettings:''') +imp('textual_colours.py','from objectstate import ObjectState, ObjectStateRegistry') +imp('textual_colours.py','from toad.settings_scope import SettingsScope') +# Global traversal stays with the owner of retained views. +patch('setting_declarations.py','Effect',''' roots = (*app.workspace_sessions.views.values(), *app.screen_stack) + return {widget for root in roots for widget in root.walk_children(widget_type)}''',''' return app.settings_widgets(widget_type)''') +patch('app.py','ToadApp',' def open_settings(self) -> SavedSettings:',''' def settings_widgets(self, widget_type): + roots = (*self.workspace_sessions.views.values(), *self.screen_stack) + return {widget for root in roots for widget in root.walk_children(widget_type)} + + def open_settings(self) -> SavedSettings:''') +# Effect readers use the target widget's own effective state. +patch('setting_effects.py','visibility','widget.display = field.value','widget.display = widget.configuration_state.get_resolved_value(field.path.value)') +old=''' for view in app.workspace_sessions.views.values(): + for window in view.query(HistoryWindow): + window.presentation_budget = replace(window.presentation_budget, buffer_viewports=field.value) + window.request_preparation()''' +new=''' for window in Effect.open_widgets(app, HistoryWindow): + value = window.configuration_state.get_resolved_value(field.path.value) + window.presentation_budget = replace(window.presentation_budget, buffer_viewports=value) + window.request_preparation()''' +patch('setting_effects.py','history_buffer_viewports',old,new) +patch('setting_widget.py','SettingVisibility','class SettingVisibility:','class SettingVisibility(SettingsReader):') +patch('setting_widget.py','SettingVisibility','self.app.saved_settings.state.get_resolved_value','self.configuration_state.get_resolved_value') +imp('setting_widget.py','from toad.settings_scope import SettingsReader') +# Reuse the inherited reader for widgets that consume local settings. +for file,names in [('widgets/prompt.py',('PromptLabel','PromptContainer')),('widgets/history_anchor.py',('HistoryWindow',)),('screens/main.py',('MainScreen',))]: + s=(root/file).read_text();tree=ast.parse(s) + for name in names: + node=next(n for n in tree.body if isinstance(n,ast.ClassDef) and n.name==name) + # Existing ToadNode-bearing owners already acquire SettingsReader through that parent. + if name=='MainScreen':continue + header=s.splitlines()[node.lineno-1] + patch(file,name,'class '+name+'(','class '+name+'(SettingsReader, ') + imp(file,'from toad.settings_scope import SettingsReader') +# Replace reads only for the admitted setting families, preserving app-owned global settings. +for file in ('widgets/prompt.py','widgets/conversation.py','widgets/history_anchor.py','widgets/tool_call.py','screens/main.py','screens/session_view.py'): + s=(root/file).read_text();tree=ast.parse(s) + for cls in (n for n in tree.body if isinstance(n,ast.ClassDef)): + chunk=ast.get_source_segment(s,cls) + new=chunk + for prefix in ('ui.input','ui.prune_','ui.history_buffer_viewports','ui.column','ui.scrollbar','ui.throbber','tools.'): + new=new.replace('self.app.settings.'+prefix,'self.settings.'+prefix) + # Two methods read the complete ui record and then local fields. + if file=='widgets/conversation.py':new=new.replace('ui = self.app.settings.ui','ui = self.settings.ui') + if file=='widgets/prompt.py':new=new.replace('ui = self.app.settings.ui','ui = self.settings.ui') + if new!=chunk:patch(file,cls.name,chunk,new) +Path('/home/ts/.cache/agent-scratch/codex-latency/p192-state.plan.json').write_text(json.dumps({'recipes':[dict(recipe_id='one-lazy-object-state-and-scoped-consumers',operations=ops)]},indent=2)+'\n') diff --git a/.verification/p192-viewed-object-configuration/p192-author-ui.py b/.verification/p192-viewed-object-configuration/p192-author-ui.py new file mode 100644 index 000000000..36a2f3d25 --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-author-ui.py @@ -0,0 +1,208 @@ +import ast,json +from pathlib import Path +root=Path('/home/ts/wt/codex-live--P181--toad/src/toad');ops=[] +def create(file,source):ops.append(dict(operation='create_file',file_path=str(root/file),source=source)) +def patch(file,target,old,new):ops.append(dict(operation='patch_target',file_path=str(root/file),target_qualname=target,replacements=[dict(old_source=old,new_source=new)])) +def imp(file,source):ops.append(dict(operation='ensure_import',file_path=str(root/file),import_source=source)) +patch('object_configuration.py','ObjectSettingField','return get_base_type_for_lazy(super().owner_type)','return DeclaredField(self.state.defaults, self.path).owner_type') +patch('object_configuration.py','ObjectConfiguration','text = FieldCodec.dumps(ObjectSettingsDocument(self.identity, values))','text = json.dumps(FieldCodec.encode(ObjectSettingsDocument(self.identity, values)), indent=2, allow_nan=False)') +imp('object_configuration.py','import json') +patch('forms/form_view.py','FieldView',' @staticmethod\n def of(state: ObjectState, parent: str = "") -> list[FieldView]:',''' field_type = DeclaredField + + @classmethod + def admits(cls, field: DeclaredField) -> bool: + return True + + @classmethod + def of(cls, state: ObjectState, parent: str = "") -> list[FieldView]:''') +patch('forms/form_view.py','FieldView',''' return [FieldView.showing(type(parameter).__mro__)(DeclaredField(state, path)) + for path, parameter in ParameterFormService().parameters_for_state(state, parent)]''',''' return [cls.showing(type(parameter).__mro__)(field) + for path, parameter in ParameterFormService().parameters_for_state(state, parent) + if cls.admits(field := cls.field_type(state, path))]''') +patch('forms/form_view.py','GroupView',' def __init__(self, field: DeclaredField) -> None:',''' field_family = FieldView + + def __init__(self, field: DeclaredField) -> None:''') +patch('forms/form_view.py','GroupView','self.parts = FieldView.of(field.state, field.path.value)','self.parts = self.field_family.of(field.state, field.path.value)') +patch('forms/form_view.py','FormView',' DEFAULT_LAYOUT = StreamLayout',' field_family = FieldView\n DEFAULT_LAYOUT = StreamLayout') +patch('forms/form_view.py','FormView.whole','FieldView.of(state)','cls.field_family.of(state)') +patch('forms/form_view.py','FormView.pages','FieldView.of(state)','cls.field_family.of(state)') +create('forms/object_configuration.py','''"""The same declared form in an object's sidebar and its context editor.""" +from formbridge.forms.parameter_info_types import DirectDataclassInfo, ParameterInfo +from textual import events +from textual.widgets import Button, Static +from toad.forms.form_view import FieldView, ValueView, GroupView, FormView +from toad.object_configuration import ObjectSettingField +from toad.widgets.acting_button import ActingButton + + +class ObjectFieldView(FieldView): + field_type = ObjectSettingField + + @classmethod + def admits(cls, field): + return field.state.shows(field.path.value) + + +class InheritSettingButton(ActingButton): + def __init__(self, field): + super().__init__("Use global default") + self.field = field + + async def act(self): + self.field.state.reset_parameter(self.field.path.value) + + +class ObjectValueView(ValueView, ObjectFieldView, shows=(ParameterInfo,)): + def __init__(self, field): + super().__init__(field) + self.source = Static("", markup=False) + self.inherit = InheritSettingButton(field) + self.listen(events.Mount, lambda event: self.mark(False)) + + def compose(self): + yield from super().compose() + yield self.source + yield self.inherit + + def mark(self, changed): + explicit = self.field.state.parameters[self.field.path.value] is not None + super().mark(explicit) + self.inherit.disabled = not explicit + scope, owner = self.field.state.get_provenance(self.field.path.value) + self.source.update("Override for this object" if explicit else + f"Inherited from global defaults · {owner.__name__}") + + +class ObjectGroupView(GroupView, ObjectFieldView, shows=(DirectDataclassInfo,)): + field_family = ObjectFieldView + + +class ObjectFormView(FormView): + field_family = ObjectFieldView +''') +create('configured_view.py','''"""A viewed object's state and its two presentations, inherited by files and conversations.""" +from functools import cached_property +from textual import events, on +from textual.geometry import Region +from textual.widgets import Button +from toad.forms.object_configuration import ObjectFormView +from toad.object_configuration import ObjectConfiguration +from toad.screens.modal import DismissibleScreen, ModalPanel +from toad.forms.presentation import FormModal +from toad.settings_scope import ViewSettingsScope +from toad.sidebar_placement import ObjectSidebarPlacement +from toad.widgets.acting_button import DismissButton +from toad.widgets.comms_menu import ContextMenu +from toad.widgets.side_bar import SideBar, SidebarPanelWidget + + +class ObjectProperties(ObjectFormView, SidebarPanelWidget): + """The sidebar edits the view's original state directly.""" + + +class ObjectConfigurationDialog(FormModal[None], DismissibleScreen[None]): + def __init__(self, state): + super().__init__() + self.form = ObjectFormView.whole(state) + + def compose(self): + with ModalPanel(): + yield self.form + yield DismissButton("Done") + + def dismiss_control(self): + self.form.finish() + super().dismiss_control() + + +class ConfiguredView(ViewSettingsScope): + @cached_property + def configuration_state(self) -> ObjectConfiguration: + identity = self.app.session_navigation.get(self.id).identity + return ObjectConfiguration(identity, self.app.saved_settings.state, + self.app.saved_settings.path().parent, self) + + @cached_property + def configuration_sidebar(self): + return SideBar(SideBar.Panel("Properties", ObjectProperties.whole(self.configuration_state), flex=True), + placement_identity=ObjectSidebarPlacement, hide=True) + + def sidebars(self): + return (*super().sidebars(), self.configuration_sidebar) + + @on(events.Unmount) + def close_configuration(self): + self.configuration_state.close() + + @on(events.MouseDown) + def configuration_menu(self, event): + if event.button != 3: + return + event.stop() + event.prevent_default() + # The properties form is shared; opening it never copies the state's values. + ContextMenu.show(self.screen, Region.from_offset(event.screen_offset, (0, 0)), + self.display_name, [(self.open_configuration, "Properties…")], + callback=lambda chosen: chosen() if chosen is not None else None) + + def open_configuration(self): + self.app.push_screen(ObjectConfigurationDialog(self.configuration_state)) +''') +patch('widgets/history_anchor.py','HistoryWindow','class HistoryWindow(SettingsReader, ', 'class HistoryWindow(') +# Object placement inherits the existing viewed-object policy, retaining its own identity. +ops.append(dict(operation='insert_before_target',file_path=str(root/'sidebar_placement.py'),target_qualname='BandRoom',source='''class ObjectSidebarPlacement(ThreadSidebarPlacement): + """Properties follow the viewed object, beside its other panels.""" + + +''')) +imp('screens/session_view.py','from toad.configured_view import ConfiguredView') +imp('screens/session_view.py','from toad.settings_scope import ConversationSettingsScope') +patch('screens/session_view.py','SidebarSessionView','class SidebarSessionView[SidebarT: ThreadSidebar](WidgetView, NavigationOwner, SessionView):','class SidebarSessionView[SidebarT: ThreadSidebar](ConfiguredView, ConversationSettingsScope, WidgetView, NavigationOwner, SessionView):') +patch('screens/session_view.py','SidebarSessionView','return (self.thread_sidebar,)','return (self.thread_sidebar, self.configuration_sidebar)') +imp('screens/file_preview.py','from toad.configured_view import ConfiguredView') +imp('screens/file_preview.py','from toad.screens.session_view import SessionContents') +imp('screens/file_preview.py','from toad.column_layout import ColumnLayout') +patch('screens/file_preview.py','FilePreviewScreen','class FilePreviewScreen(SessionView,','class FilePreviewScreen(ConfiguredView, SessionView,') +patch('screens/file_preview.py','FilePreviewScreen','with Vertical():','with FileContents():') +ops.append(dict(operation='insert_before_target',file_path=str(root/'screens/file_preview.py'),target_qualname='FilePreviewScreen',source='''class FileContents(ColumnLayout, SessionContents): + def apply_layout_preferences(self): + super().apply_layout_preferences() + self.apply_column_layout() + + +''')) +create('column_layout.py','''"""Column geometry shared by every viewed document.""" +from textual.scrollable_container import ScrollableContainer +from toad.settings_scope import SettingsReader + + +class ColumnLayout(SettingsReader): + def apply_column_layout(self): + ui = self.settings.ui + self.styles.max_width = max(10, ui.column_width) if ui.column else None + for reader in self.walk_children(ScrollableContainer): + reader.styles.scrollbar_size_vertical = ui.scrollbar.size +''') +# Conversation composes the same width/scrollbar procedure as a file. +s=(root/'widgets/conversation.py').read_text();t=ast.parse(s);c=next(n for n in t.body if isinstance(n,ast.ClassDef) and n.name=='Conversation') +patch('widgets/conversation.py','Conversation','class Conversation(','class Conversation(ColumnLayout, ') +patch('widgets/conversation.py','Conversation',''' ui = self.settings.ui + self.styles.max_width = max(10, ui.column_width) if ui.column else None + self.window.styles.scrollbar_size_vertical = ui.scrollbar.size''',''' self.apply_column_layout()''') +imp('widgets/conversation.py','from toad.column_layout import ColumnLayout') +# Remove the parallel file-menu identity/map: the actual action is the menu choice. +patch('project_link.py','ProjectFileLink',''' from toad.widgets.comms_menu import show_target_menu + + full_path = str(path) + event.prevent_default() + show_target_menu(widget.screen, Region.from_offset(event.screen_offset, (0, 0)), full_path, + [("copy_path", "Copy full path")], + {"copy_path": partial(widget.app.copy_to_clipboard, full_path)})''',''' from toad.widgets.comms_menu import ContextMenu + + full_path = str(path) + event.prevent_default() + copy_path = partial(widget.app.copy_to_clipboard, full_path) + ContextMenu.show(widget.screen, Region.from_offset(event.screen_offset, (0, 0)), full_path, + [(copy_path, "Copy full path")], + callback=lambda chosen: chosen() if chosen is not None else None)''') +Path('/home/ts/.cache/agent-scratch/codex-latency/p192-ui.plan.json').write_text(json.dumps({'recipes':[dict(recipe_id='shared-object-properties-form-sidebar-and-context',operations=ops)]},indent=2)+'\n') diff --git a/.verification/p192-viewed-object-configuration/p192-lazy-settings-declarations.plan.json b/.verification/p192-viewed-object-configuration/p192-lazy-settings-declarations.plan.json new file mode 100644 index 000000000..307f449f9 --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-lazy-settings-declarations.plan.json @@ -0,0 +1,178 @@ +{ + "recipes": [ + { + "recipe_id": "settings-inherit-native-lazy-configuration-membership", + "operations": [ + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/setting_declarations.py", + "import_source": "from objectstate.lazy_factory import LazyResolutionDataclass" + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/setting_declarations.py", + "import_source": "from python_introspect import AnnotatedDataclassValidationMixin" + }, + { + "operation": "insert_before_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/setting_declarations.py", + "target_qualname": "FamilyChoices", + "source": "class SettingsDeclaration(AnnotatedDataclassValidationMixin, LazyResolutionDataclass):\n \"\"\"An actual settings record, recognized by ObjectState's lazy configuration factory.\"\"\"" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "InputSettings", + "replacements": [ + { + "old_source": "class InputSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class InputSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "UiSettings", + "replacements": [ + { + "old_source": "class UiSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class UiSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "NotificationsSettings", + "replacements": [ + { + "old_source": "class NotificationsSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class NotificationsSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "SidebarSettings", + "replacements": [ + { + "old_source": "class SidebarSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class SidebarSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "SidebarArrangements", + "replacements": [ + { + "old_source": "class SidebarArrangements(AnnotatedDataclassValidationMixin):", + "new_source": "class SidebarArrangements(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "AgentSettings", + "replacements": [ + { + "old_source": "class AgentSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class AgentSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "ToolsSettings", + "replacements": [ + { + "old_source": "class ToolsSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class ToolsSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "ShellSettings", + "replacements": [ + { + "old_source": "class ShellSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class ShellSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "LauncherSettings", + "replacements": [ + { + "old_source": "class LauncherSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class LauncherSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "StatisticsSettings", + "replacements": [ + { + "old_source": "class StatisticsSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class StatisticsSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "UpdateSettings", + "replacements": [ + { + "old_source": "class UpdateSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class UpdateSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "target_qualname": "ToadSettings", + "replacements": [ + { + "old_source": "class ToadSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class ToadSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/preferences.py", + "import_source": "from toad.setting_declarations import SettingsDeclaration" + }, + { + "operation": "patch_target", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/patch.py", + "target_qualname": "DiffSettings", + "replacements": [ + { + "old_source": "class DiffSettings(AnnotatedDataclassValidationMixin):", + "new_source": "class DiffSettings(SettingsDeclaration):" + } + ] + }, + { + "operation": "ensure_import", + "file_path": "/home/ts/wt/codex-live--P181--toad/src/toad/patch.py", + "import_source": "from toad.setting_declarations import SettingsDeclaration" + } + ] + } + ] +} diff --git a/.verification/p192-viewed-object-configuration/p192-object-control.py b/.verification/p192-viewed-object-configuration/p192-object-control.py new file mode 100644 index 000000000..65de7798e --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-object-control.py @@ -0,0 +1,81 @@ +import asyncio,json +from pathlib import Path +from objectstate import ObjectStateRegistry +from textual.containers import Vertical +from toad.textual_colours import ThemedApp +from toad.settings_scope import ConversationSettingsScope, ViewSettingsScope +from toad.object_configuration import ObjectConfiguration +from toad.tab_identity.kinds import PreviewTab +from toad.configured_view import ObjectProperties, ObjectConfigurationDialog +from toad.forms.object_configuration import ObjectFormView + +DIRECTORY=Path('/home/ts/.cache/agent-scratch/codex-latency/p192-object-control') +DIRECTORY.mkdir(exist_ok=True) +# Every file this control can write is beneath its explicit private directory. +for file in (DIRECTORY/'objects').glob('*.json') if (DIRECTORY/'objects').exists() else (): + file.unlink() + +class Conversation(ConversationSettingsScope,Vertical): + def __init__(self,defaults): + super().__init__() + self.state=ObjectConfiguration(PreviewTab(DIRECTORY/'conversation.txt'),defaults,DIRECTORY,self) + self.panel=ObjectProperties.whole(self.state) + @property + def configuration_state(self):return self.state + def compose(self):yield self.panel + +class FileOwner(ViewSettingsScope): + @property + def configuration_state(self): return self.state + +class Control(ThemedApp): + def compose(self): + self.view=Conversation(self.configuration_state) + yield self.view + +async def main(): + app=Control() + async with app.run_test(size=(110,40)) as pilot: + state=app.view.state + assert 'ui.column_width' in state.editable_paths + assert 'ui.input.placeholder' in state.editable_paths + assert 'ui.theme' not in state.editable_paths + assert state.get_resolved_value('ui.column_width')==100 + app.configuration_state.update_parameter('ui.input.placeholder','Nested live default') + assert state.get_resolved_value('ui.input.placeholder')=='Nested live default' + assert state.get_provenance('ui.input.placeholder')[0]=='' + app.configuration_state.update_parameter('ui.column_width',111) + assert state.get_resolved_value('ui.column_width')==111 + # The sidebar and context form both use the same actual ObjectState. + dialog=ObjectConfigurationDialog(state) + assert dialog.form.state is app.view.panel.state is state + state.field('ui.column_width').edit(111) + app.configuration_state.update_parameter('ui.column_width',112) + assert state.get_resolved_value('ui.column_width')==111 + await pilot.pause() + app.push_screen(dialog) + await pilot.pause() + value=dialog.form.values['ui.column_width'] + await value.inherit.act() + assert state.get_resolved_value('ui.column_width')==112 + assert 'ui.column_width' not in json.loads(state.path.read_text())['values'] + # Both forms paint the same resolved value after reset. + await pilot.pause() + assert app.view.panel.values['ui.column_width'].editor.value=='112' + assert value.editor.value=='112' + state.field('ui.column_width').edit(125) + saved=state.path + assert saved.is_relative_to(DIRECTORY) + dialog.dismiss_control() + state.close() + reopened=ObjectConfiguration(state.identity,app.configuration_state,DIRECTORY,app.view) + assert reopened.get_resolved_value('ui.column_width')==125 + assert reopened.parameters['ui.column_width']==125 + reopened.close() + file_owner=FileOwner() + file_state=file_owner.state=ObjectConfiguration(PreviewTab(DIRECTORY/'file.txt'),app.configuration_state,DIRECTORY,file_owner) + assert 'ui.column_width' in file_state.editable_paths + assert 'ui.input.placeholder' not in file_state.editable_paths + file_state.close() + print('PASS: live defaults, explicit-equal override, sidebar/context shared edit, reset, original source, persistence, file/conversation scope') +asyncio.run(main()) diff --git a/.verification/p192-viewed-object-configuration/p192-object-control.txt b/.verification/p192-viewed-object-configuration/p192-object-control.txt new file mode 100644 index 000000000..8e061cbf2 --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-object-control.txt @@ -0,0 +1 @@ +PASS: live defaults, explicit-equal override, sidebar/context shared edit, reset, original source, persistence, file/conversation scope diff --git a/.verification/p192-viewed-object-configuration/p192-objectstate-author.py b/.verification/p192-viewed-object-configuration/p192-objectstate-author.py new file mode 100644 index 000000000..29354d3d6 --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-objectstate-author.py @@ -0,0 +1,75 @@ +import ast,json +from pathlib import Path +r=Path('/home/ts/wt/codex-live--P192--objectstate');file=r/'src/objectstate/context_manager.py';s=file.read_text();n=next(n for n in ast.parse(s).body if isinstance(n,ast.FunctionDef) and n.name=='extract_all_configs');old=ast.get_source_segment(s,n) +new='''def extract_all_configs(context_obj) -> Dict[type, Any]: + """Read every declared nested configuration, retaining its nominal identity. + + Each stored object is visited once. Repeated paths to the same object are + shared; different objects claiming the same config type still refuse. + Stored values are read without invoking lazy resolution in the layer being built. + """ + configs: Dict[type, Any] = {} + pending = deque([(context_obj, "context object")]) + visited: set[int] = set() + while pending: + current, source = pending.popleft() + if current is None or id(current) in visited: + continue + visited.add(id(current)) + if is_dataclass(current): + _record_config_instance(configs, current, source=source) + for field_name, declared_type in _declared_config_fields(type(current)): + value = object.__getattribute__(current, field_name) + if value is None: + continue + if not _is_compatible_config_type(value, declared_type): + raise TypeError( + f"{type(current).__name__}.{field_name} declares " + f"{declared_type.__name__}, got {type(value).__name__}." + ) + pending.append((value, f"{source}.{field_name}")) + else: + children: Dict[type, Any] = {} + _extract_from_object_attributes_typed(current, children) + pending.extend((child, f"{source}.{kind.__name__}") + for kind, child in children.items()) + return configs''' +ops=[dict(operation='ensure_import',file_path=str(file),import_source='from collections import deque'),dict(operation='patch_target',file_path=str(file),target_qualname='extract_all_configs',replacements=[dict(old_source=old,new_source=new)])] +tests='''def test_extract_all_configs_visits_nested_and_shared_records() -> None: + @dataclass + class Leaf: + value: str = "original" + + @dataclass + class Branch: + first: Leaf + shared: Leaf + + @dataclass + class Root: + branch: Branch + + leaf = Leaf() + root = Root(Branch(leaf, leaf)) + assert extract_all_configs(root) == {Root: root, Branch: root.branch, Leaf: leaf} + + +def test_extract_all_configs_refuses_collision_below_a_direct_child() -> None: + @dataclass + class Leaf: + value: str + + @dataclass + class Branch: + first: Leaf + second: Leaf + + @dataclass + class Root: + branch: Branch + + with pytest.raises(ValueError, match="second Leaf instance"): + extract_all_configs(Root(Branch(Leaf("first"), Leaf("second")))) +''' +ops.append(dict(operation='insert_after_target',file_path=str(r/'tests/test_context_manager.py'),target_qualname='test_extract_all_configs_accepts_concrete_value_for_lazy_declared_owner',source=tests)) +Path('/home/ts/.cache/agent-scratch/codex-latency/p192-objectstate.plan.json').write_text(json.dumps({'recipes':[dict(recipe_id='collect-the-entire-declared-configuration-graph',operations=ops)]},indent=2)) diff --git a/.verification/p192-viewed-object-configuration/p192-objectstate-controls.txt b/.verification/p192-viewed-object-configuration/p192-objectstate-controls.txt new file mode 100644 index 000000000..a6c0f7e3c --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-objectstate-controls.txt @@ -0,0 +1,15 @@ +/home/ts/code/projects/agent-comms/.venv/lib/python3.14/site-packages/pytest_asyncio/plugin.py:300: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset. +The event loop scope for asynchronous fixtures will default to the "fixture" caching scope. Future versions of pytest-asyncio will default the loop scope for asynchronous fixtures to "function" scope. Set the default fixture loop scope explicitly in order to avoid unexpected behavior in the future. Valid fixture loop scopes are: "function", "class", "module", "package", "session" + + warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET)) +.............. [100%] +=============================== warnings summary =============================== +../../code/projects/agent-comms/.venv/lib/python3.14/site-packages/anyio/pytest_plugin.py:14 + /home/ts/code/projects/agent-comms/.venv/lib/python3.14/site-packages/anyio/pytest_plugin.py:14: PytestRemovedIn10Warning: _pytest.python.CallSpec2 has been renamed to CallSpec. + The CallSpec2 alias will be removed in pytest 10. + Update imports to use CallSpec instead. + See https://docs.pytest.org/en/stable/deprecations.html#callspec2-renamed + from _pytest.python import CallSpec2 + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +14 passed, 1 warning in 0.03s diff --git a/.verification/p192-viewed-object-configuration/p192-view-lifecycle.txt b/.verification/p192-viewed-object-configuration/p192-view-lifecycle.txt new file mode 100644 index 000000000..41fded877 --- /dev/null +++ b/.verification/p192-viewed-object-configuration/p192-view-lifecycle.txt @@ -0,0 +1,20 @@ +/home/ts/code/projects/agent-comms/.venv/lib/python3.14/site-packages/pytest_asyncio/plugin.py:300: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset. +The event loop scope for asynchronous fixtures will default to the "fixture" caching scope. Future versions of pytest-asyncio will default the loop scope for asynchronous fixtures to "function" scope. Set the default fixture loop scope explicitly in order to avoid unexpected behavior in the future. Valid fixture loop scopes are: "function", "class", "module", "package", "session" + + warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET)) +. [100%] +=============================== warnings summary =============================== +../../code/projects/agent-comms/.venv/lib/python3.14/site-packages/anyio/pytest_plugin.py:14 + /home/ts/code/projects/agent-comms/.venv/lib/python3.14/site-packages/anyio/pytest_plugin.py:14: PytestRemovedIn10Warning: _pytest.python.CallSpec2 has been renamed to CallSpec. + The CallSpec2 alias will be removed in pytest 10. + Update imports to use CallSpec instead. + See https://docs.pytest.org/en/stable/deprecations.html#callspec2-renamed + from _pytest.python import CallSpec2 + +src/toad/acp/agent_process.py:13 +src/toad/acp/agent_process.py:13 + /home/ts/wt/codex-live--P181--toad/src/toad/acp/agent_process.py:13: DeprecationWarning: Using `ClientSideConnection` directly is deprecated, please use `acp.connect_to_agent` instead. + from acp import Client, ClientSideConnection, schema + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +1 passed, 3 warnings in 10.24s diff --git a/pyproject.toml b/pyproject.toml index 0c35cd294..4c7cc9a2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,9 +45,9 @@ agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } -objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", branch = "main" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", branch = "main" } +objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "226fcd8918e16f6d1b594cac0bdcf8057aab832a" } [project.scripts] toad = "toad.cli:main" diff --git a/src/toad/app.py b/src/toad/app.py index 4686da920..71161cb83 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -336,6 +336,10 @@ def _background_render_finished(self, task: asyncio.Task[object]) -> None: self.background_render_slots.release() + def settings_widgets(self, widget_type): + roots = (*self.workspace_sessions.views.values(), *self.screen_stack) + return {widget for root in roots for widget in root.walk_children(widget_type)} + def open_settings(self) -> SavedSettings: path = SavedSettings.path() raw = json.loads(path.read_text("utf-8")) if path.exists() else {} diff --git a/src/toad/app_node.py b/src/toad/app_node.py index e2d6ef018..23e4e52a7 100644 --- a/src/toad/app_node.py +++ b/src/toad/app_node.py @@ -8,12 +8,13 @@ from textual import events from textual.dom import DOMNode from textual import on +from toad.settings_scope import SettingsReader if TYPE_CHECKING: from toad.app import ToadApp -class ToadNode(DOMNode): +class ToadNode(SettingsReader): """Keep the application refinement on one shared native parent.""" app: ToadApp diff --git a/src/toad/column_layout.py b/src/toad/column_layout.py new file mode 100644 index 000000000..b580b10f3 --- /dev/null +++ b/src/toad/column_layout.py @@ -0,0 +1,11 @@ +"""Column geometry shared by every viewed document.""" +from textual.containers import ScrollableContainer +from toad.settings_scope import SettingsReader + + +class ColumnLayout(SettingsReader): + def apply_column_layout(self): + ui = self.settings.ui + self.styles.max_width = max(10, ui.column_width) if ui.column else None + for reader in self.walk_children(ScrollableContainer): + reader.styles.scrollbar_size_vertical = ui.scrollbar.size diff --git a/src/toad/configured_view.py b/src/toad/configured_view.py new file mode 100644 index 000000000..65383968e --- /dev/null +++ b/src/toad/configured_view.py @@ -0,0 +1,84 @@ +"""A viewed object's state and its two presentations, inherited by files and conversations.""" +from functools import cached_property +from textual import events, on +from textual.geometry import Region +from toad.forms.object_configuration import ObjectFormView +from toad.object_configuration import ObjectConfiguration +from toad.screens.modal import DismissibleScreen, ModalPanel +from toad.forms.presentation import FormModal +from toad.session_tracker import SidebarState +from toad.settings_scope import ObjectSidebarHost, ViewSettingsScope +from toad.sidebar_placement import ObjectSidebarPlacement +from toad.widgets.acting_button import DismissButton +from toad.widgets.comms_menu import ActionMenu +from toad.widgets.side_bar import ( + LazyThreadSidebar, + SideBar, + SidebarPanelWidget, +) + + +class ObjectSidebar(LazyThreadSidebar): + placement_type = ObjectSidebarPlacement + + def __init__(self, configuration): + self.configuration = configuration + super().__init__(state=SidebarState()) + + def make_panels(self): + return (SideBar.Panel("Properties", ObjectProperties.whole(self.configuration), flex=True),) + + +class ObjectProperties(ObjectFormView, SidebarPanelWidget): + """The sidebar edits the view's original state directly.""" + + +class ObjectConfigurationDialog(FormModal[None], DismissibleScreen[None]): + def __init__(self, state): + super().__init__() + self.form = ObjectFormView.whole(state) + + def compose(self): + with ModalPanel(): + yield self.form + yield DismissButton("Done") + + def dismiss_control(self): + self.form.finish() + super().dismiss_control() + + +class ConfiguredView(ViewSettingsScope): + @cached_property + def configuration_state(self) -> ObjectConfiguration: + identity = self.app.session_navigation.get(self.id).identity + return ObjectConfiguration(identity, self.app.saved_settings.state, + self.app.saved_settings.path().parent, self) + + @cached_property + def configuration_sidebar(self): + return ObjectSidebar(self.configuration_state) + + def sidebars(self): + return (*super().sidebars(), self.configuration_sidebar) + + @on(events.Mount) + async def mount_configuration(self): + await self.query_one(ObjectSidebarHost).mount(self.configuration_sidebar) + + @on(events.Unmount) + def close_configuration(self): + self.configuration_state.close() + + @on(events.Click) + def configuration_menu(self, event): + if event.button != 3: + return + event.stop() + event.prevent_default() + # The properties form is shared; opening it never copies the state's values. + ActionMenu.show(self.screen, Region.from_offset(event.screen_offset, (0, 0)), + self.display_name, [(self.open_configuration, "Properties…")]) + + def open_configuration(self): + self.app.push_screen(ObjectConfigurationDialog(self.configuration_state)) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index e81549e53..3b8f01189 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -22,7 +22,7 @@ resolve_annotated, ) from python_introspect.validation import validate_annotation_value -from textual import containers +from textual import containers, events from textual.app import ComposeResult from textual.css.scalar import Scalar from textual.geometry import Spacing @@ -70,6 +70,10 @@ def __init__(self, field: DeclaredField) -> None: self.field = field super().__init__(**self.control_arguments()) self.bind_control() + self.listen(events.Mount, self.control_mounted) + + def control_mounted(self, event: events.Mount) -> None: + self.call_after_refresh(self.paint) def control_arguments(self) -> dict[str, object]: """Arguments for the native control; containers need no initial value.""" diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index ba179fba3..5c0d52a39 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -66,15 +66,30 @@ def __init__(self, field: DeclaredField) -> None: def margin(self) -> Spacing: return Spacing(0, 0, 0 if self.last_child else 1, 0) - @staticmethod - def of(state: ObjectState, parent: str = "") -> list[FieldView]: + field_type = DeclaredField + + @classmethod + def family(cls): + return FieldView + + @classmethod + def members(cls): + return (member for member in super().members() if member.family() is cls.family()) + + @classmethod + def admits(cls, field: DeclaredField) -> bool: + return True + + @classmethod + def of(cls, state: ObjectState, parent: str = "") -> list[FieldView]: """Show ObjectState's direct children; FormBridge supplies each field's kind. ObjectState already extracted this topology. Reading it does not rebuild a second form tree or re-analyze the held object's values. """ - return [FieldView.showing(type(parameter).__mro__)(DeclaredField(state, path)) - for path, parameter in ParameterFormService().parameters_for_state(state, parent)] + return [cls.showing(type(parameter).__mro__)(field) + for path, parameter in ParameterFormService().parameters_for_state(state, parent) + if cls.admits(field := cls.field_type(state, path))] @abstractmethod def values(self) -> Iterator[ValueView]: @@ -144,9 +159,11 @@ def border(self) -> Edges: colours.colour(ForegroundColour).multiply_alpha(0.2)) return Edges(*(("tall", colour),) * 4) + field_family = FieldView + def __init__(self, field: DeclaredField) -> None: super().__init__(field) - self.parts = FieldView.of(field.state, field.path.value) + self.parts = self.field_family.of(field.state, field.path.value) def compose(self) -> ComposeResult: yield GroupTitle(self.field.label) @@ -171,6 +188,7 @@ class FormView(WidgetView, containers.VerticalScroll, can_focus=False): long form does not hold up the frame it opens in. """ + field_family = FieldView DEFAULT_LAYOUT = StreamLayout height = None overflow_y = "scroll" @@ -188,12 +206,12 @@ def __init__(self, state: ObjectState, parts: list[FieldView]) -> None: @classmethod def whole(cls, state: ObjectState) -> FormView: """Every shown field of the state on one form.""" - return cls(state, FieldView.of(state)) + return cls(state, cls.field_family.of(state)) @classmethod def pages(cls, state: ObjectState) -> dict[FormPage, FormView]: """One form per page the top-level fields declare, in declaration order.""" - parts = {part.field.path: part for part in FieldView.of(state)} + parts = {part.field.path: part for part in cls.field_family.of(state)} return {page: cls(state, [parts[path] for path in paths]) for page, paths in form_pages(state, parts).items()} @@ -209,7 +227,9 @@ def leave_state(self, event: events.Unmount) -> None: def values_changed(self, paths: set[str]) -> None: for path in paths & self.values.keys(): - self.values[path].editor.paint() + editor = self.values[path].editor + if editor.is_mounted: + editor.paint() 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 new file mode 100644 index 000000000..a18ef8dfe --- /dev/null +++ b/src/toad/forms/object_configuration.py @@ -0,0 +1,63 @@ +"""The same declared form in an object's sidebar and its context editor.""" +from formbridge.forms.parameter_info_types import DirectDataclassInfo, ParameterInfo +from textual import events +from textual.widgets import Static +from toad.forms.form_view import FieldView, ValueView, GroupView, FormView +from toad.object_configuration import ObjectSettingField +from toad.widgets.acting_button import ActingButton + + +class ObjectFieldView(FieldView): + field_type = ObjectSettingField + + @classmethod + def family(cls): + return ObjectFieldView + + @classmethod + def admits(cls, field): + return field.state.shows(field.path.value) + + +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 ObjectValueView(ValueView, ObjectFieldView, shows=(ParameterInfo,)): + def __init__(self, field): + super().__init__(field) + self.source = Static("", markup=False) + self.inherit = InheritSettingButton(field) + self.listen(events.Mount, lambda event: self.mark(False)) + + def compose(self): + yield from super().compose() + yield self.source + yield self.inherit + + def mark(self, changed): + explicit = self.field.state.parameters[self.field.path.value] is not None + super().mark(explicit) + self.inherit.label = "Use global default" if explicit else "Override here" + if explicit: + self.source.update("Override for this object") + else: + scope, owner = self.field.state.get_provenance(self.field.path.value) + self.source.update("Inherited from global defaults") + self.source.tooltip = f"{owner.__module__}.{owner.__qualname__} · {scope or 'global'}" + + +class ObjectGroupView(GroupView, ObjectFieldView, shows=(DirectDataclassInfo,)): + field_family = ObjectFieldView + + +class ObjectFormView(FormView): + field_family = ObjectFieldView diff --git a/src/toad/object_configuration.py b/src/toad/object_configuration.py new file mode 100644 index 000000000..7355e8636 --- /dev/null +++ b/src/toad/object_configuration.py @@ -0,0 +1,105 @@ +"""A viewed object's overrides, with ObjectState owning inheritance and its source.""" +import json +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path + +from agent_comms.nominal.field_codec import FieldCodec +from formbridge.forms.declared_fields import DeclaredField +from objectstate import ObjectState, ObjectStateRegistry, ScopeKey +from objectstate.lazy_factory import LazyDataclassFactory +from pydantic import JsonValue + +from toad import atomic +from toad.setting_declarations import Effect, SettingsDeclaration +from toad.settings_scope import SettingsScope +from toad.tab_identity import TabIdentity + + +@dataclass(frozen=True) +class ObjectSettingsDocument: + identity: TabIdentity + values: dict[str, JsonValue] + + +class ObjectSettingField(DeclaredField): + """The original concrete declaration, over a lazy field's actual state.""" + + @property + def declaration(self): + return self.state.defaults.parameter_structure.declarations[self.path.value] + + @property + def owner_type(self): + return DeclaredField(self.state.defaults, self.path).owner_type + + +class ObjectConfiguration(ObjectState): + """One view's explicit values; unassigned fields follow its live defaults.""" + + def __init__(self, identity: TabIdentity, defaults: ObjectState, + directory: Path, owner: SettingsScope) -> None: + self.identity, self.defaults, self.owner = identity, defaults, owner + self.path = directory / "objects" / f"{identity.mode_name}.json" + scope = str(ScopeKey.of("view", identity.mode_name)) + if ObjectStateRegistry.get_by_scope(scope) is not None: + raise ValueError(f"Settings already open for {identity}") + lazy_type = LazyDataclassFactory.make_lazy_simple(type(defaults.object_instance)) + super().__init__(lazy_type(), scope_id=scope, parent_state=defaults) + if self.path.exists(): + document = FieldCodec.decode(ObjectSettingsDocument, FieldCodec.parse(self.path.read_text("utf-8"))) + if document.identity != identity: + raise ValueError(f"Settings identity does not match {self.path}") + for path, encoded in document.values.items(): + if path not in self.editable_paths: + raise ValueError(f"{path} cannot be overridden for {identity}") + field = self.field(path) + field.edit(FieldCodec.decode(field.annotation, encoded)) + self.mark_saved() + ObjectStateRegistry.register(self) + self.on_resolved_changed(self.apply_changes) + self.on_state_changed(self.save_changes) + + def field(self, path: str) -> ObjectSettingField: + return ObjectSettingField(self, self.parameter_structure.path(path)) + + @cached_property + def editable_paths(self) -> frozenset[str]: + return frozenset(path.value for path in self.parameter_structure.leaf_paths() + if self.admits(path.value)) + + def admits(self, path: str) -> bool: + field = self.field(path) + owner = field.owner_type + return (issubclass(owner, SettingsDeclaration) + and owner.admits_field(field.path.field_name, self.owner)) + + def shows(self, path: str) -> bool: + return any(name == path or name.startswith(path + ".") for name in self.editable_paths) + + def update_parameter(self, param_name: str, value: object) -> set[str]: + if param_name not in self.editable_paths: + raise ValueError(f"{param_name} cannot be overridden for {self.identity}") + return super().update_parameter(param_name, value) + + def apply_changes(self, paths: set[str]) -> None: + for path in paths & self.editable_paths: + field = self.field(path) + if (effect := field.metadata(Effect)) is not None: + effect.apply(self.owner, field) + + def save_changes(self, paths: set[str]) -> None: + if not paths: + return + values = {path: FieldCodec.encode(value, self.field(path).annotation) + for path, value in self.get_current_values().items() + if path in self.editable_paths and value is not None} + text = json.dumps(FieldCodec.encode(ObjectSettingsDocument(self.identity, values)), indent=2, allow_nan=False) + self.path.parent.mkdir(parents=True, exist_ok=True) + atomic.write(str(self.path), text) + self.mark_saved() + + def close(self) -> None: + self.off_resolved_changed(self.apply_changes) + self.off_state_changed(self.save_changes) + ObjectStateRegistry.unregister(self) diff --git a/src/toad/patch.py b/src/toad/patch.py index 7aced13a4..524e8e0b8 100644 --- a/src/toad/patch.py +++ b/src/toad/patch.py @@ -18,7 +18,7 @@ RemovedCodeRole, TextRole, ) -from toad.setting_declarations import Effect, FamilyChoices +from toad.setting_declarations import Effect, FamilyChoices, SettingsDeclaration _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") @@ -181,7 +181,7 @@ def fitted_width(cls, available: int, content: int) -> int: @dataclass(frozen=True) -class DiffSettings(AnnotatedDataclassValidationMixin): +class DiffSettings(SettingsDeclaration): @Effect def changed(app, field) -> None: app.colours.refresh_line_renderer() diff --git a/src/toad/preferences.py b/src/toad/preferences.py index 892fdd9a8..1b6f086c0 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -54,15 +54,16 @@ UnreadCount, TopNavigationBar, ) -from toad.setting_declarations import FamilyChoices +from toad.setting_declarations import FamilyChoices, SettingsDeclaration from toad.setting_widget import PromptSettingEffects +from toad.settings_scope import ConversationSettingsScope, ViewSettingsScope from toad.terminal_origin import TerminalLayout from toad.version import CheckedVersionMonitor, VersionMonitor from toad.widgets.presentation_window import PresentationBudget @dataclass(frozen=True) -class InputSettings(AnnotatedDataclassValidationMixin): +class InputSettings(SettingsDeclaration, scopes=(ConversationSettingsScope,)): box: Annotated[ type[InputBox], FieldLabel("Layout"), FamilyChoices(InputBox), PromptSettingEffects.apply_input_layout, @@ -91,13 +92,30 @@ class InputSettings(AnnotatedDataclassValidationMixin): @dataclass(frozen=True) -class UiSettings(AnnotatedDataclassValidationMixin): - wheel_scroll_rows: Annotated[ - int, FieldLabel("Rows per mouse wheel notch"), Ge(1), effects.wheel_scroll_rows, - WireName("wheel-scroll-rows"), - ] = 1 - """Vertical rows per wheel event in transcripts and sidebars. Increase for faster travel.""" +class ViewUiSettings(SettingsDeclaration, scopes=(ViewSettingsScope,)): + column: Annotated[bool, FieldLabel("Enable column?"), effects.conversation_layout] = False + """Enable for a fixed column size. Disable to use the full screen width.""" + column_width: Annotated[ + int, FieldLabel("Width of the column"), Ge(40), effects.conversation_layout, + WireName("column-width"), + ] = 100 + """Width of the column if enabled. Minimum 40 characters.""" + column_alignment: Annotated[ + type[ColumnAlignment], FieldLabel("Column alignment"), + FamilyChoices(ColumnAlignment), effects.conversation_layout, + WireName("column-alignment"), + ] = LeftColumnAlignment + """Place the conversation column on the left, in the center, or on the right.""" + scrollbar: Annotated[ + type[Scrollbar], + FieldLabel("Scrollbar size"), + FamilyChoices(Scrollbar), + effects.conversation_layout, + ] = NormalScrollbar + +@dataclass(frozen=True) +class ConversationUiSettings(ViewUiSettings, scopes=(ConversationSettingsScope,)): history_buffer_viewports: Annotated[ int, FieldLabel("History buffer in message-area heights"), @@ -106,6 +124,39 @@ class UiSettings(AnnotatedDataclassValidationMixin): WireName("history-buffer-viewports"), ] = PresentationBudget().buffer_viewports """Prepare and retain this distance on both sides of the reader. Memory and widget limits still apply.""" + input: Annotated[InputSettings, FieldLabel("Input box")] = field( + default_factory=InputSettings + ) + """The prompt you type messages and shell commands into.""" + info_bar: Annotated[bool, FieldLabel("Enable info bar?"), effects.visibility, WireName("info-bar"),] = True + """The info bar is the text below the prompt text area. Disable for more space.""" + status_line: Annotated[ + bool, FieldLabel("Show status line in the info bar?"), effects.visibility, + WireName("status-line"), + ] = True + """The status line shows tokens and cost (not available in all agents).""" + agent_title: Annotated[ + bool, FieldLabel("Show agent title the info bar?"), effects.visibility, + WireName("agent-title"), + ] = True + """Disable for a little extras space.""" + throbber: Annotated[ + type[LoadingStyle], FieldLabel("Thinking animation"), FamilyChoices(LoadingStyle) + ] = PulseLoading + """Animation to show while waiting for the agent to respond""" + prune_low_mark: Annotated[int, FieldLabel("Target lines"), Ge(100)] = 1500 + """Keep at least this many number of lines of conversation (minimum 100).""" + prune_excess: Annotated[int, FieldLabel("Additional lines"), Ge(0)] = 1000 + """Start removing lines when the conversation has this number of lines more than the target (see 'Target lines').""" + + +@dataclass(frozen=True) +class UiSettings(ConversationUiSettings): + wheel_scroll_rows: Annotated[ + int, FieldLabel("Rows per mouse wheel notch"), Ge(1), effects.wheel_scroll_rows, + WireName("wheel-scroll-rows"), + ] = 1 + """Vertical rows per wheel event in transcripts and sidebars. Increase for faster travel.""" theme: Annotated[str, SchemeName(), effects.theme] = "wal" """wal follows pywal's palette in true colour. ANSI themes inherit your terminal palette.""" @@ -125,11 +176,6 @@ class UiSettings(AnnotatedDataclassValidationMixin): keymap: Keymap = field(default_factory=Keymap) """Keyboard overrides, edited in the keybinding menu.""" - input: Annotated[InputSettings, FieldLabel("Input box")] = field( - default_factory=InputSettings - ) - """The prompt you type messages and shell commands into.""" - sessions_bar: Annotated[ type[SessionBar], FieldLabel("Show sessions bar?"), @@ -142,37 +188,6 @@ class UiSettings(AnnotatedDataclassValidationMixin): footer: Annotated[bool, FieldLabel("Enable footer?"), effects.visibility] = True """Disable the footer if you want additional room.""" - info_bar: Annotated[bool, FieldLabel("Enable info bar?"), effects.visibility, WireName("info-bar"),] = True - """The info bar is the text below the prompt text area. Disable for more space.""" - - status_line: Annotated[ - bool, FieldLabel("Show status line in the info bar?"), effects.visibility, - WireName("status-line"), - ] = True - """The status line shows tokens and cost (not available in all agents).""" - - agent_title: Annotated[ - bool, FieldLabel("Show agent title the info bar?"), effects.visibility, - WireName("agent-title"), - ] = True - """Disable for a little extras space.""" - - column: Annotated[bool, FieldLabel("Enable column?"), effects.conversation_layout] = False - """Enable for a fixed column size. Disable to use the full screen width.""" - - column_width: Annotated[ - int, FieldLabel("Width of the column"), Ge(40), effects.conversation_layout, - WireName("column-width"), - ] = 100 - """Width of the column if enabled. Minimum 40 characters.""" - - column_alignment: Annotated[ - type[ColumnAlignment], FieldLabel("Column alignment"), - FamilyChoices(ColumnAlignment), effects.conversation_layout, - WireName("column-alignment"), - ] = LeftColumnAlignment - """Place the conversation column on the left, in the center, or on the right.""" - navigation_bar: Annotated[ type[NavigationBarPosition], FieldLabel("Navigation bar"), FamilyChoices(NavigationBarPosition), effects.conversation_layout, @@ -180,18 +195,6 @@ class UiSettings(AnnotatedDataclassValidationMixin): ] = TopNavigationBar """Show the menu, history arrows and tabs above the conversation or below it.""" - scrollbar: Annotated[ - type[Scrollbar], - FieldLabel("Scrollbar size"), - FamilyChoices(Scrollbar), - effects.conversation_layout, - ] = NormalScrollbar - - throbber: Annotated[ - type[LoadingStyle], FieldLabel("Thinking animation"), FamilyChoices(LoadingStyle) - ] = PulseLoading - """Animation to show while waiting for the agent to respond""" - flash_duration: Annotated[float, FieldLabel("Flash duration"), Ge(0.5)] = 3.0 """Default duration of flash messages (in seconds)""" @@ -199,15 +202,13 @@ class UiSettings(AnnotatedDataclassValidationMixin): """Automatically copy text on selection? Doesn't apply to text areas (use ctrl+c to copy).""" - prune_low_mark: Annotated[int, FieldLabel("Target lines"), Ge(100)] = 1500 - """Keep at least this many number of lines of conversation (minimum 100).""" - prune_excess: Annotated[int, FieldLabel("Additional lines"), Ge(0)] = 1000 - """Start removing lines when the conversation has this number of lines more than the target (see 'Target lines').""" + + @dataclass(frozen=True) -class NotificationsSettings(AnnotatedDataclassValidationMixin): +class NotificationsSettings(SettingsDeclaration): system: Annotated[ type[NotificationPolicy], FieldLabel("Show Toad notifications on your desktop?"), @@ -228,7 +229,7 @@ class NotificationsSettings(AnnotatedDataclassValidationMixin): @dataclass(frozen=True) -class SidebarSettings(AnnotatedDataclassValidationMixin): +class SidebarSettings(SettingsDeclaration): hide: Annotated[bool, FieldLabel("Hide the sidebar when not in use?")] = False show_stopped: Annotated[ @@ -254,20 +255,20 @@ class SidebarSettings(AnnotatedDataclassValidationMixin): @mark_ui_hidden_config @dataclass(frozen=True) -class SidebarArrangements(AnnotatedDataclassValidationMixin): +class SidebarArrangements(SettingsDeclaration): """Where each sidebar sits and how large it is, saved per terminal origin.""" terminals: tuple[TerminalLayout, ...] = () @dataclass(frozen=True) -class AgentSettings(AnnotatedDataclassValidationMixin): +class AgentSettings(SettingsDeclaration, scopes=(ConversationSettingsScope,)): thoughts: Annotated[bool, FieldLabel("Agent thoughts"), effects.visibility] = True """Show agent's 'thoughts' in the conversation?""" @dataclass(frozen=True) -class ToolsSettings(AnnotatedDataclassValidationMixin): +class ToolsSettings(SettingsDeclaration, scopes=(ConversationSettingsScope,)): expand: Annotated[ type[Expansion], FieldLabel("Tool call expand"), FamilyChoices(Expansion) ] = FailExpansion @@ -275,7 +276,7 @@ class ToolsSettings(AnnotatedDataclassValidationMixin): @dataclass(frozen=True) -class ShellSettings(AnnotatedDataclassValidationMixin): +class ShellSettings(SettingsDeclaration): command: Annotated[str, FieldLabel("Shell command")] = "/bin/sh" """Command used to launch your shell on macOS. [bold]Note:[/] Requires restart.""" @@ -305,12 +306,12 @@ class ShellSettings(AnnotatedDataclassValidationMixin): @dataclass(frozen=True) -class LauncherSettings(AnnotatedDataclassValidationMixin): +class LauncherSettings(SettingsDeclaration): agents: Annotated[str, FieldLabel("Agents to show in the launcher"), Multiline()] = "" @dataclass(frozen=True) -class StatisticsSettings(AnnotatedDataclassValidationMixin): +class StatisticsSettings(SettingsDeclaration): allow_collect: Annotated[ bool, FieldLabel("Allow collection of anonymous usage data?") ] = True @@ -324,7 +325,7 @@ async def collect(self, operation: Callable[[], Awaitable[None]]) -> None: @dataclass(frozen=True) -class UpdateSettings(AnnotatedDataclassValidationMixin): +class UpdateSettings(SettingsDeclaration): check: Annotated[bool, FieldLabel("Check for a newer Toad at startup?")] = True """A second after it starts, Toad fetches the latest release's version from batrachian.ai and says when a newer one is out.""" @@ -341,7 +342,7 @@ def monitor(self) -> VersionMonitor: @dataclass(frozen=True) -class ToadSettings(AnnotatedDataclassValidationMixin): +class ToadSettings(SettingsDeclaration): anon_id: str = field(default="", metadata={"ui_hidden": True}) """Anonymous installation ID""" @@ -436,7 +437,7 @@ def __init__( raw = {} if raw is None else raw self.root = FieldPath(root) - self.state = ObjectState(FieldCodec.decode(root, raw)) + self.state = ObjectState(FieldCodec.decode(root, raw), scope_id="") self.in_file = frozenset(self.keys_held("", raw)) self.user_set = set(self.in_file) self.applied, self.report_error = applied, report_error diff --git a/src/toad/project_link.py b/src/toad/project_link.py index 6e251fd1a..1ca99efc4 100644 --- a/src/toad/project_link.py +++ b/src/toad/project_link.py @@ -178,13 +178,13 @@ def preview_file(self, widget, path: Path) -> None: widget.app.run_worker(partial(widget.app.session_navigation.preview, path, position=self.position)) def copy_file(self, widget, event, path: Path) -> None: - from toad.widgets.comms_menu import show_target_menu + from toad.widgets.comms_menu import ActionMenu full_path = str(path) event.prevent_default() - show_target_menu(widget.screen, Region.from_offset(event.screen_offset, (0, 0)), full_path, - [("copy_path", "Copy full path")], - {"copy_path": partial(widget.app.copy_to_clipboard, full_path)}) + copy_path = partial(widget.app.copy_to_clipboard, full_path) + ActionMenu.show(widget.screen, Region.from_offset(event.screen_offset, (0, 0)), full_path, + [(copy_path, "Copy full path")]) @dataclass(frozen=True) diff --git a/src/toad/screens/file_preview.py b/src/toad/screens/file_preview.py index 8354324f3..53fdd0669 100644 --- a/src/toad/screens/file_preview.py +++ b/src/toad/screens/file_preview.py @@ -4,15 +4,23 @@ from collections.abc import Callable from textual.app import ComposeResult +from toad.column_layout import ColumnLayout +from toad.configured_view import ConfiguredView from toad.session_actions import ReturnToViewAction, CloseViewAction from textual.containers import Horizontal, Vertical -from toad.screens.session_view import SessionView +from toad.screens.session_view import SessionBody, SessionContents, SessionView from textual.widget import Widget from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import TabHistoryControls -class FilePreviewScreen(SessionView, can_focus=False, actions=(ReturnToViewAction, CloseViewAction)): +class FileContents(ColumnLayout, SessionContents): + def apply_layout_preferences(self): + super().apply_layout_preferences() + self.apply_column_layout() + + +class FilePreviewScreen(ConfiguredView, SessionView, can_focus=False, actions=(ReturnToViewAction, CloseViewAction)): footer_compact = True # FilePreview scrolls in its body (1325acc3), so the body takes focus and the close key. @@ -27,5 +35,6 @@ def project_root(self) -> Path: return self.project_path def compose(self) -> ComposeResult: - with Vertical(): - yield self.preview + with SessionBody(): + with FileContents(): + yield self.preview diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index a1466c28b..0d3183169 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -54,6 +54,7 @@ ) from toad.session_scope import ViewSessionOrigin from toad.session_tracker import SessionDetails +from toad.settings_scope import SettingsReader from toad.thread_identity import NoThreadIdentity, ThreadIdentity from toad.widget_actions import ( CyclesSessions, @@ -461,7 +462,7 @@ def watch_title(self, title: str) -> None: self.app.terminal_attention.update() def get_loading_widget(self) -> Widget: - return self.app.settings.ui.throbber.widget(self) + return self.settings.ui.throbber.widget(self) def activate_session(self) -> None: # The identity is updated by ACP's coordination notification and diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index 9409e89f5..a39a73bc4 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -6,6 +6,8 @@ from textual.view import FILL, WidgetView from textual.color import Color from textual.layout import Layout +from toad.configured_view import ConfiguredView +from toad.settings_scope import ConversationSettingsScope, ObjectSidebarHost from ui_theme.scheme import BackgroundColour from textual.reactive import reactive from contextlib import contextmanager @@ -173,7 +175,7 @@ def retained_native_presentations(self): return () -class SessionBody(WidgetView, ToadNode, Center): +class SessionBody(WidgetView, ToadNode, ObjectSidebarHost, Center): """The shared sidebar/content layout reads its owner directly.""" width = height = FILL @@ -195,11 +197,11 @@ def on_mount(self) -> None: def apply_layout_preferences(self) -> None: """Place a width-constrained conversation where the column alignment setting puts it.""" - self.align_horizontal = self.app.settings.ui.column_alignment.horizontal + self.align_horizontal = self.settings.ui.column_alignment.horizontal self.refresh(layout=True) -class SidebarSessionView[SidebarT: ThreadSidebar](WidgetView, NavigationOwner, SessionView): +class SidebarSessionView[SidebarT: ThreadSidebar](ConfiguredView, ConversationSettingsScope, WidgetView, NavigationOwner, SessionView): """One session-owned sidebar survives construction, mounting and retirement. Composition mounts this resource; it does not create its identity. Chrome @@ -225,5 +227,5 @@ def make_thread_sidebar(self) -> SidebarT: ... def thread_sidebar(self) -> SidebarT: return self.make_thread_sidebar() - def sidebars(self) -> tuple[SidebarT, ...]: - return (self.thread_sidebar,) + def sidebars(self) -> tuple[SideBar, ...]: + return (self.thread_sidebar, self.configuration_sidebar) diff --git a/src/toad/setting_declarations.py b/src/toad/setting_declarations.py index 0fcff63f8..116cc8846 100644 --- a/src/toad/setting_declarations.py +++ b/src/toad/setting_declarations.py @@ -2,12 +2,15 @@ from __future__ import annotations +from annotationlib import Format, get_annotations from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, ClassVar, cast from agent_comms.nominal.declared_choice import DeclaredChoice -from python_introspect import AnnotationChoices +from objectstate.lazy_factory import LazyResolutionDataclass +from python_introspect import AnnotatedDataclassValidationMixin, AnnotationChoices +from toad.settings_scope import SettingsScope if TYPE_CHECKING: from formbridge.forms.declared_fields import DeclaredField @@ -15,6 +18,26 @@ from toad.app import ToadApp +class SettingsDeclaration(AnnotatedDataclassValidationMixin, LazyResolutionDataclass): + """An actual settings record and the scopes its own field declarations admit.""" + + configuration_scopes: ClassVar[tuple[type[SettingsScope], ...]] = () + + def __init_subclass__(cls, *, scopes: tuple[type[SettingsScope], ...] = (), **kwargs): + super().__init_subclass__(**kwargs) + cls.configuration_scopes = scopes + + @classmethod + def field_source(cls, name: str) -> type[SettingsDeclaration]: + return next(owner for owner in cls.__mro__ + if name in get_annotations(owner, format=Format.STRING)) + + @classmethod + def admits_field(cls, name: str, scope: SettingsScope) -> bool: + return any(isinstance(scope, admitted) + for admitted in cls.field_source(name).configuration_scopes) + + @dataclass(frozen=True) class FamilyChoices[ChoiceT: DeclaredChoice](AnnotationChoices): family: type[ChoiceT] @@ -34,8 +57,7 @@ class Effect: @staticmethod def open_widgets(app, widget_type): - roots = (*app.workspace_sessions.views.values(), *app.screen_stack) - return {widget for root in roots for widget in root.walk_children(widget_type)} + return app.settings_widgets(widget_type) class EachOpenWidget(Effect): diff --git a/src/toad/setting_effects.py b/src/toad/setting_effects.py index 14df65330..58bbca184 100644 --- a/src/toad/setting_effects.py +++ b/src/toad/setting_effects.py @@ -19,7 +19,7 @@ def visibility(app: ToadApp, field: DeclaredField) -> None: from toad.widgets.prompt import Prompt for widget in Effect.open_widgets(app, SettingVisibility): if widget.visibility_setting == field.path.value: - widget.display = field.value + widget.display = widget.configuration_state.get_resolved_value(field.path.value) for prompt in Effect.open_widgets(app, Prompt): prompt.refresh(layout=True) @@ -31,10 +31,10 @@ def wheel_scroll_rows(app: ToadApp, field: DeclaredField) -> None: @Effect def history_buffer_viewports(app: ToadApp, field: DeclaredField) -> None: - for view in app.workspace_sessions.views.values(): - for window in view.query(HistoryWindow): - window.presentation_budget = replace(window.presentation_budget, buffer_viewports=field.value) - window.request_preparation() + for window in Effect.open_widgets(app, HistoryWindow): + value = window.configuration_state.get_resolved_value(field.path.value) + window.presentation_budget = replace(window.presentation_budget, buffer_viewports=value) + window.request_preparation() @Effect diff --git a/src/toad/setting_widget.py b/src/toad/setting_widget.py index 2cfe662b4..767b77937 100644 --- a/src/toad/setting_widget.py +++ b/src/toad/setting_widget.py @@ -4,16 +4,17 @@ from toad.setting_declarations import EachOpenWidget from textual.events import Mount from textual import on +from toad.settings_scope import SettingsReader -class SettingVisibility: +class SettingVisibility(SettingsReader): def __init_subclass__(cls, *, setting: str, **kwargs): super().__init_subclass__(**kwargs) cls.visibility_setting = setting @on(Mount) def on_mount(self): - self.display = self.app.saved_settings.state.get_resolved_value(self.visibility_setting) + self.display = self.configuration_state.get_resolved_value(self.visibility_setting) class PromptSettingEffects: diff --git a/src/toad/settings_scope.py b/src/toad/settings_scope.py new file mode 100644 index 000000000..0a93f2ef1 --- /dev/null +++ b/src/toad/settings_scope.py @@ -0,0 +1,41 @@ +"""The configuration state owned by an application or one viewed object.""" +from abc import abstractmethod +from objectstate import ObjectState +from textual.dom import DOMNode +from textual.widget import Widget + + +class SettingsReader(DOMNode): + @property + def configuration_scope(self) -> SettingsScope: + return next((node for node in self.walk_ancestors(with_self=True) + if isinstance(node, SettingsScope)), self.app) + + @property + def configuration_state(self) -> ObjectState: + return self.configuration_scope.configuration_state + + @property + def settings(self): + return self.configuration_state.to_resolved_object() + + +class SettingsScope(SettingsReader): + @property + @abstractmethod + def configuration_state(self) -> ObjectState: ... + + def settings_widgets(self, widget_type): + return self.walk_children(widget_type) + + +class ViewSettingsScope(SettingsScope): + """A file, thread or channel owns its display overrides.""" + + +class ConversationSettingsScope(ViewSettingsScope): + """A thread or channel additionally owns conversation and composer settings.""" + + +class ObjectSidebarHost(Widget): + """The native body in which a viewed object's properties are placed.""" diff --git a/src/toad/sidebar_placement.py b/src/toad/sidebar_placement.py index b0e47803e..540ac98f9 100644 --- a/src/toad/sidebar_placement.py +++ b/src/toad/sidebar_placement.py @@ -356,6 +356,14 @@ def heading(cls, viewed: Iterable[str]) -> str: return next(iter(viewed), cls.scope) +class ObjectSidebarPlacement(ThreadSidebarPlacement): + """Properties follow the viewed object, beside its other panels.""" + + @classmethod + def heading(cls, viewed: Iterable[str]) -> str: + return f"{super().heading(viewed)} properties" + + @dataclass class BandRoom(ABC): """One band's room while its bars take it, named by the band's most open fold.""" diff --git a/src/toad/target_commands.py b/src/toad/target_commands.py index 8bde03ea9..5a1ff76a0 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -69,7 +69,7 @@ def available_actions(self): return actions def show_menu(self, sidebar, offset): - from toad.widgets.comms_menu import show_target_menu + from toad.widgets.comms_menu import ActionMenu selected_screen = sidebar.app.screen source = sidebar.app.selected_session async def read(): @@ -78,10 +78,9 @@ async def read(): or not sidebar.model_state.serves(self.observed.root) or sidebar.app.screen is not selected_screen): return - show_target_menu(selected_screen, Region.from_offset(offset, (0, 0)), self.title, - [(command.command.removeprefix('/'), command.label(self)) for command in choices], - {command.command.removeprefix('/'): partial(self.execute_menu, sidebar, command) - for command in choices}) + ActionMenu.show(selected_screen, Region.from_offset(offset, (0, 0)), self.title, + [(partial(self.execute_menu, sidebar, command), command.label(self)) + for command in choices]) sidebar.run_worker(read(), name='target-menu', group='target-menu', exclusive=True) async def command_choices(self): diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index ef3a446a6..b3eb4231b 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -21,6 +21,7 @@ from agent_comms.cli_verbs.cli_result import ResultShower from agent_comms.nominal.field_codec import FieldCodec from agent_comms_hooks import ToolRefusedError +from objectstate import ObjectState, ObjectStateRegistry from rich.color import Color as RichColor from rich.console import RenderableType from rich.segment import Segment @@ -38,6 +39,7 @@ from textual.strip import Strip from textual.style import Style as VisualStyle from textual.theme import Theme +from toad.settings_scope import SettingsScope from ui_theme.backend import Backend, TerminalBackend from ui_theme.catalog import SCHEMES from ui_theme.clock import AnimationClock @@ -516,7 +518,7 @@ class _ThemedAppMeta(_MessagePumpMeta, ABCMeta): """Compose Textual message dispatch with the result display capability.""" -class ThemedApp(App[ReturnType], ResultShower, metaclass=_ThemedAppMeta): +class ThemedApp(SettingsScope, App[ReturnType], ResultShower, metaclass=_ThemedAppMeta): """A Textual app drawn through ui_theme: mix in before `App`. Holds the colours owner and answers Textual's three questions from it: the @@ -531,11 +533,16 @@ class ThemedApp(App[ReturnType], ResultShower, metaclass=_ThemedAppMeta): def __init__(self) -> None: # Line styles read the diff settings, so a themed app holds settings before its colours. self.saved_settings = self.open_settings() + ObjectStateRegistry.register(self.saved_settings.state) # Textual asks for the colour system and the palette while it builds the app. self.colours = TextualColours(self) # type: ignore[arg-type] super().__init__() self.colours.register_catalog() + @property + def configuration_state(self) -> ObjectState: + return self.saved_settings.state + def open_settings(self) -> SavedSettings: """Settings held by this app alone; an app with a settings file opens it instead.""" return SavedSettings() diff --git a/src/toad/widgets/comms_menu.py b/src/toad/widgets/comms_menu.py index 2cbff6239..730aeb395 100644 --- a/src/toad/widgets/comms_menu.py +++ b/src/toad/widgets/comms_menu.py @@ -5,6 +5,7 @@ from typing import cast from textual import events, on +from textual._callback import invoke from textual.view import ( AUTO, FILL, @@ -96,16 +97,15 @@ def on_click(self, event: events.Click) -> None: self.dismiss(None) -def show_target_menu( - screen, - anchor: Region, - title: str, - items: list[tuple[str, str]], - actions: dict[str, Callable[[], None]], -) -> None: - """Use the same modal menu for contextual declarations.""" - def handle(choice: str | None) -> None: - if choice and choice in actions: - actions[choice]() +class ActionMenu(ContextMenu[Callable[[], object]]): + """The selected row carries the actual operation; there is no second lookup table.""" + + @classmethod + def show(cls, screen, anchor: Region, title: str, items): + return super().show(screen, anchor, title, items, callback=cls.run_action) + + @staticmethod + async def run_action(action): + if action is not None: + await invoke(action) - ContextMenu.show(screen, anchor, title, items, callback=handle) diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 915a85699..075552c7d 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -329,18 +329,16 @@ async def apply(self, explorer: ContextExplorer, model: ContextNode) -> None: class ContextCorrect(SelectedSourceButton): def apply(self, explorer: ContextExplorer, model: ContextNode) -> None: - from toad.widgets.comms_menu import show_target_menu + from toad.widgets.comms_menu import ActionMenu answers = model.correction_answers() if not answers: self.notify("Select an original annotation to correct", severity="warning") return - show_target_menu( - self.screen, - self.region, - "Correct original answer", - [(answer.declared_name, answer.public_title()) for answer in answers], - {answer.declared_name: partial(self._correct, explorer, model, answer) for answer in answers}, + ActionMenu.show( + self.screen, self.region, "Correct original answer", + [(partial(self._correct, explorer, model, answer), answer.public_title()) + for answer in answers], ) @work(group="context-correction", exclusive=True) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 5c376f6a1..0a76c13ea 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -57,6 +57,7 @@ from textual.view import AUTO, FILL from textual.widget import Widget from textual.widgets import Static +from toad.column_layout import ColumnLayout from ui_theme.effects import CURSOR_BLINK, Mix from ui_theme.scheme import BackgroundColour, ForegroundColour, SurfaceHoverColour @@ -994,7 +995,7 @@ def operational_sources(self) -> ConversationSources: ... def initial_editing_mode(self) -> EditingMode: ... -class Conversation( +class Conversation(ColumnLayout, GoalView[GoalSession], TranscriptView, FollowsLayoutPreferences, @@ -1769,9 +1770,7 @@ async def on_mount(self) -> None: def apply_layout_preferences(self) -> None: """Project the original settings without retaining their semantic values.""" - ui = self.app.settings.ui - self.styles.max_width = max(10, ui.column_width) if ui.column else None - self.window.styles.scrollbar_size_vertical = ui.scrollbar.size + self.apply_column_layout() async def initialize_view(self) -> None: """Initialize the agent view, separate from the framework mount event.""" @@ -1927,8 +1926,8 @@ async def check_prune(self) -> None: """Check if a prune is required.""" if self._require_check_prune: self._require_check_prune = False - low_mark = self.app.settings.ui.prune_low_mark - high_mark = low_mark + self.app.settings.ui.prune_excess + low_mark = self.settings.ui.prune_low_mark + high_mark = low_mark + self.settings.ui.prune_excess if self.transcript.readable: # Height pressure is not source evidence. Dropping the source # pager or an uncovered wire claim lets a later observation diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index 448898b51..f4411dd03 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -14,6 +14,7 @@ from metaclass_registry.mro_dispatch import handles from textual import events from textual.widgets.text_area import VimMode +from toad.settings_scope import SettingsReader from toad.widget_actions import Action, NativeMethodKeys from textual.containers import VerticalScroll from textual.geometry import Size @@ -1103,7 +1104,7 @@ def prepare_viewport(self) -> None: @cached_property def presentation_budget(self) -> PresentationBudget: - return PresentationBudget(buffer_viewports=self.app.settings.ui.history_buffer_viewports) + return PresentationBudget(buffer_viewports=self.settings.ui.history_buffer_viewports) @cached_property def lookahead(self) -> DirectionalPreparation: diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 8392a8a93..c8fb4477b 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -18,6 +18,7 @@ FocusWithinView, WidgetView, ) +from toad.settings_scope import SettingsReader from toad.widget_actions import Activates from textual._paint_state import PaintState from textual.actions import SkipAction @@ -161,7 +162,7 @@ class AgentInfo( @property def padding(self): - return self.app.settings.ui.input.box.agent_info_padding + return self.settings.ui.input.box.agent_info_padding @property def primary_activation(self): @@ -312,34 +313,34 @@ class InfoBar( @property def margin(self): - ui = self.app.settings.ui + ui = self.settings.ui return ui.input.box.info_bar_margin(ui.footer) -class PromptLabel(WidgetView, Label): +class PromptLabel(SettingsReader, WidgetView, Label): width = AUTO @property def padding(self): - return self.app.settings.ui.input.box.label_padding + return self.settings.ui.input.box.label_padding def render(self): return self.query_ancestor(Prompt).label_glyph() -class PromptContainer(FocusWithinView, containers.HorizontalGroup): +class PromptContainer(SettingsReader, FocusWithinView, containers.HorizontalGroup): @property def margin(self): - return self.app.settings.ui.input.box.frame_margin + return self.settings.ui.input.box.frame_margin @property def padding(self): - return self.app.settings.ui.input.box.frame_padding + return self.settings.ui.input.box.frame_padding @property def border(self): colour = self.app.colours.colour(PrimaryColour if self.has_focus_within else MutedColour) - return self.app.settings.ui.input.box.frame_border(colour) + return self.settings.ui.input.box.frame_border(colour) @on(events.MouseDown) def on_mouse_down(self, event: events.MouseUp) -> None: @@ -972,7 +973,7 @@ def update_prompt(self): "Enter shell command\t[r]▌esc▐[/r] prompt mode" ).expand_tabs(8) if shell - else self.app.settings.ui.input.placeholder + else self.settings.ui.input.placeholder ) editor.highlight_language = "shell" if shell else "markdown" self.prompt_label.refresh() @@ -1042,7 +1043,7 @@ def suggest(self, suggestion: str) -> None: self.prompt_text_area.suggestion = suggestion[len(self.text) :] def label_glyph(self) -> Content: - symbols = self.app.settings.ui.input + symbols = self.settings.ui.input if self.shell_mode: return Content.styled(symbols.shell_symbol, "$text-primary") return Content.styled( @@ -1144,7 +1145,7 @@ def update_prompt(self) -> None: self.prompt_label.refresh() def label_glyph(self) -> Content: - return Content.styled(self.app.settings.ui.input.agent_symbol, "$text-secondary") + return Content.styled(self.settings.ui.input.agent_symbol, "$text-secondary") def compose_info_popups(self) -> ComposeResult: return () diff --git a/src/toad/widgets/session_thread_sidebar.py b/src/toad/widgets/session_thread_sidebar.py index 493402fb0..8f2c55fc0 100644 --- a/src/toad/widgets/session_thread_sidebar.py +++ b/src/toad/widgets/session_thread_sidebar.py @@ -1,10 +1,13 @@ """Session side panels retain reader position while their view is parked.""" from typing import TYPE_CHECKING from weakref import ref -from textual.app import ComposeResult from textual.worker import WorkerCancelled from toad.plan import PlanItem -from toad.widgets.side_bar import AbsentPanels, PanelHydration, ThreadSidebar +from toad.widgets.side_bar import ( + AbsentPanels, + LazyThreadSidebar, + PanelHydration, +) from toad.widgets.session_thread_panels import PlanSessionPanel, SessionPanel from toad.widgets.presentation_window import NativePresentation from toad.widgets.project_panel import NO_PROJECT_VIEW @@ -13,23 +16,20 @@ from toad.screens.main import MainScreen -class SessionThreadSidebar(ThreadSidebar, NativePresentation): +class SessionThreadSidebar(LazyThreadSidebar, NativePresentation): """Keep warm panels until eviction releases their widgets.""" def __init__(self, screen: MainScreen) -> None: self._owner = ref(screen) self._panel_owners = tuple(member() for member in SessionPanel.members_with(SessionPanel)) self.plan = next(owner for owner in self._panel_owners if isinstance(owner, PlanSessionPanel)) - super().__init__(state=screen._thread_sidebar_state, defer_mount=True) + super().__init__(state=screen._thread_sidebar_state) @property def retained_widget_count(self) -> int: return 1 + self.descendant_count if self.panels_loaded else 0 - @property - def wants_panels(self) -> bool: - """A collapsed session sidebar keeps its panels released until it opens.""" - return super().wants_panels and not self.collapsed + async def prepare_presentation(self) -> None: self.schedule_hydration() @@ -78,10 +78,8 @@ def capture_panels(self, screen: MainScreen) -> None: owner.capture(panel.widget) screen._project_panel = NO_PROJECT_VIEW - def _compose_panels(self) -> ComposeResult: - if not self.panels: - screen = self._owner() - if screen is None: - raise ReferenceError("The session sidebar owner has retired") - self.panels.extend(owner.compose_panel(screen) for owner in self._panel_owners) - yield from super()._compose_panels() + def make_panels(self): + screen = self._owner() + if screen is None: + raise ReferenceError("The session sidebar owner has retired") + return (owner.compose_panel(screen) for owner in self._panel_owners) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 413932281..6fe6121b2 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -1251,9 +1251,11 @@ def dismiss_control(self) -> None: class ThreadSidebar(SideBar): + placement_type = ThreadSidebarPlacement + def __init__(self, *panels, state: SidebarState, **kwargs): self.state = state - super().__init__(*panels, placement_identity=ThreadSidebarPlacement, hide=True, **kwargs) + super().__init__(*panels, placement_identity=self.placement_type, hide=True, **kwargs) self.collapsed = self.hide @property @@ -1265,3 +1267,22 @@ def mount_collapse(self) -> None: def store_collapsed(self, value: bool) -> None: self.collapsed = value + + +class LazyThreadSidebar(ThreadSidebar): + """Create panel widgets only when this view's sidebar opens.""" + + def __init__(self, *panels, **kwargs): + super().__init__(*panels, defer_mount=True, **kwargs) + + @property + def wants_panels(self) -> bool: + return super().wants_panels and not self.collapsed + + @abstractmethod + def make_panels(self): ... + + def _compose_panels(self): + if not self.panels: + self.panels.extend(self.make_panels()) + yield from super()._compose_panels() diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index f055aa161..98b024e0f 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -44,7 +44,7 @@ from toad.thread_identity import SessionIdentity, ThreadIdentity from toad.widget_actions import OpensSelected, WidgetAction from toad.widgets.checkbox import Checkbox -from toad.widgets.comms_menu import show_target_menu +from toad.widgets.comms_menu import ActionMenu from toad.widgets.comms_sidebar import ThreadStatusRow from toad.widgets.session_sort import SortControl, ThreadSortForm from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarVisibilityObserver @@ -601,12 +601,6 @@ def open_selected(): if generation == self._generation and row.is_attached and row.available: self.request_navigation(row, entry=entry, generation=generation) - items = [("open", "Open")] if row.available else [] - items.append(("copy", "Copy name")) - show_target_menu( - self.screen, - Region.from_offset(offset, (0, 0)), - target, - items, - {"open": open_selected, "copy": lambda: self.app.copy_to_clipboard(target)}, - ) + items = [(open_selected, "Open")] if row.available else [] + items.append((lambda: self.app.copy_to_clipboard(target), "Copy name")) + ActionMenu.show(self.screen, Region.from_offset(offset, (0, 0)), target, items) diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index ccdb94aab..a5bbc1189 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -206,7 +206,7 @@ def check_expand(self) -> None: if self.output.suppress_auto_expansion: # Don't auto expand reads, as it can generate a lot of noise return - tool_call_expand = self.app.settings.tools.expand + tool_call_expand = self.settings.tools.expand status = tool_call if tool_call_expand.expands_preview(status, self.output.preview_fits): self._auto_expanded = True diff --git a/uv.lock b/uv.lock index 385cba1a9..2c99cf2ec 100644 --- a/uv.lock +++ b/uv.lock @@ -285,7 +285,7 @@ requires-dist = [ { name = "metaclass-registry", git = "https://github.com/OpenHCSDev/metaclass-registry?branch=main" }, { name = "msgpack", specifier = "==1.2.2" }, { name = "notify-py", specifier = "==0.3.43" }, - { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400" }, + { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=226fcd8918e16f6d1b594cac0bdcf8057aab832a" }, { name = "packaging", specifier = "==26.2" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "platformdirs", specifier = "==4.9.6" }, @@ -294,12 +294,12 @@ requires-dist = [ { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?branch=main" }, - { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=95db444b536cba27081dd2c1496f71e25670a446" }, - { name = "textual-image", extras = ["textual"], git = "https://github.com/OpenHCSDev/textual-image.git?rev=1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" }, + { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?branch=main" }, + { name = "textual-image", extras = ["textual"], git = "https://github.com/OpenHCSDev/textual-image.git?branch=main" }, { name = "textual-serve", specifier = "==1.1.3" }, { name = "textual-speedups", specifier = "==0.2.1" }, { name = "typeguard", specifier = "==4.5.2" }, - { name = "typing-extensions", specifier = "==4.15.0" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, { name = "ui-theme", git = "https://github.com/OpenHCSDev/ui-theme.git?branch=main" }, { name = "watchdog", specifier = "==6.0.0" }, { name = "xdg-base-dirs", specifier = ">=6.0.2" }, @@ -888,7 +888,7 @@ wheels = [ [[package]] name = "objectstate" version = "5.0.0" -source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400#b5dd9f78b9293df145d7629f21deb89b042b9400" } +source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=226fcd8918e16f6d1b594cac0bdcf8057aab832a#226fcd8918e16f6d1b594cac0bdcf8057aab832a" } dependencies = [ { name = "dill" }, { name = "pycodify" }, @@ -1389,7 +1389,7 @@ wheels = [ [[package]] name = "textual-diff-view" version = "0.1.5" -source = { git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=95db444b536cba27081dd2c1496f71e25670a446#95db444b536cba27081dd2c1496f71e25670a446" } +source = { git = "https://github.com/OpenHCSDev/textual-diff-view.git?branch=main#b9d14d8ac45b225e1a07d19bbf2e0ac68e1050f1" } dependencies = [ { name = "textual" }, ] @@ -1397,7 +1397,7 @@ dependencies = [ [[package]] name = "textual-image" version = "0.14.1" -source = { git = "https://github.com/OpenHCSDev/textual-image.git?rev=1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80#1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } +source = { git = "https://github.com/OpenHCSDev/textual-image.git?branch=main#5086f63674d6bbf17fb3e96e8d6c253527da710c" } dependencies = [ { name = "pillow" }, { name = "rich" },