diff --git a/.scratch/forms-source.py b/.scratch/forms-source.py new file mode 100644 index 000000000..f53394459 --- /dev/null +++ b/.scratch/forms-source.py @@ -0,0 +1,70 @@ +"""NRA source evidence for the existing forms and category-filter families.""" +import ast +import json +import subprocess +import sys +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule, module_syntax_index +from nominal_refactor_advisor.class_index import build_class_family_index + +roots = [Path('/home/ts/wt/toad-forms-migration/src/toad'), + Path('/home/ts/wt/textual-native-forms/src/textual'), + Path('/home/ts/wt/objectstate-native-forms/src/objectstate'), + Path('/home/ts/wt/formbridge-native-forms/src/formbridge'), + Path('/home/ts/wt/pyqt-native-formbridge/src/pyqt_reactive'), + Path('/home/ts/wt/core-forms-migration/src/agent_comms'), + Path('/home/ts/code/projects/openhcs/openhcs')] +terms = {'message_category', 'apply_block_filter', 'keep_live_block', 'block_category', + 'selected_categories', 'visible_categories', 'set_display_constraint', + 'update_classes', 'set_class', 'has_class', 'parameters_for_state', + 'is_optional', 'WidgetCreationConfig', 'widget_creation_type', + 'shown_in', 'FieldView', 'DeclaredField', 'FormView', 'SortDialog', + 'NewThreadCliCommand', 'ForkCliCommand', 'NewThreadSpec', 'ForkSpec', + 'CommsForkTool', 'initial_prompt', 'pi_bin', 'prompt', 'TargetField', + 'TargetAction', 'ModelConfigOption', 'ThinkingLevelConfigOption', + 'ConfigOptions', 'CommandDialog', 'NativeThreadDefaults', + 'wire', 'Comms', 'CommsRoute', 'resolve_comms_route', 'LaunchRuntime', + 'CoordinationAccess', 'CoordinationTranscriptReader', 'WireRelationshipSource', + 'DetachedTranscriptReader', 'ObservedTranscriptReader', 'accepts_launch', + 'sync_identity', 'on_comms_session_named', 'rename_coordination', 'coordinated_title', + 'ModelSwitcher', 'ModelSelection', 'ConnectProvider', 'filter_models', 'select_model', + 'provider_login', 'ConfigChoice', 'SelectChoices', 'configuration_changed', + 'admission', 'admit', 'custody', 'receipt', '__setattr__', + 'ImportSnapshot', 'ImportBuffer', 'ImportAdapter', 'CodexImportScan', + 'SessionMetaCodexRecord', 'import_thread', 'title', 'stem', + 'AgentSession', 'SessionDisposition', 'reconnect', 'record_failure', + 'retirement', 'settled', 'ObservationProcess', '_close_all', + 'AsyncResourceRelease', 'stop_observing', + 'LayoutKind', 'LayoutStrategy', 'title_components', 'is_nested', + 'unwrapped_type', 'WidgetCreationPipeline', 'WidgetCreationRuntime'} +modules, classes, sites, errors = [], [], [], [] +for root in roots: + for path in root.rglob('*.py'): + try: + source = path.read_text() + qt = Path('/home/ts/wt/pyqt-native-formbridge') + if '--qt-before' in sys.argv and path in { + qt / 'src/pyqt_reactive/forms/widget_creation_config.py', + qt / 'src/pyqt_reactive/forms/widget_creation_types.py'}: + source = subprocess.check_output( + ['git', '-C', str(qt), 'show', 'HEAD:' + str(path.relative_to(qt))], text=True) + parsed = SourceModule.from_source_path(path, source).parse() + except Exception as error: + errors.append((str(path), str(error))) + continue + modules.append(parsed) + syntax = module_syntax_index(parsed.module) + for _, node in syntax.indexed_nodes_of_type(ast.ClassDef): + classes.append((str(path), node.lineno, node.name, [ast.unparse(base) for base in node.bases])) + for _, node in syntax.indexed_nodes_of_type(ast.ImportFrom): + if any(name.name in terms for name in node.names): + sites.append((str(path), node.lineno, 'import', ast.unparse(node))) + for kind in (ast.Call, ast.Attribute, ast.Name, ast.FunctionDef, ast.AnnAssign, ast.Assign): + for _, node in syntax.indexed_nodes_of_type(kind): + text = ast.unparse(node) + if any(term in text for term in terms) and len(text) < 2500: + sites.append((str(path), node.lineno, kind.__name__, text)) +index = build_class_family_index(modules) +print(json.dumps({'roots': list(map(str, roots)), 'modules': len(modules), + 'classes': classes, 'indexed_classes': len(index.classes_by_symbol), + 'sites': sites, 'parse_errors': errors}, indent=2)) diff --git a/pyproject.toml b/pyproject.toml index 5c18431f9..b83a60781 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,8 @@ dependencies = [ "agent-comms[acp]", "agent-client-protocol==0.12.1", "ui-theme", - "formbridge==0.1.0", - "objectstate==2.1.1", + "formbridge==0.1.5", + "objectstate==4.0.1", ] [tool.uv.workspace] @@ -39,8 +39,10 @@ members = ["toad"] [tool.uv.sources] ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "2f19daf21f9fb6f22aafb0e1f470a9bb949a3633" } -agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "688c0c5c23c71973abe823041f6c4d530b4c8ef4" } -textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "18f4e7a67403d56846ef5abf55c93e3e0639be33" } +agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "e56c4004aacef25f763a36f525227e1dd642db4a" } +textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "e2fd8a336c49db0f16f22fc52618b378e30968cf" } +objectstate = { git = "https://github.com/OpenHCSDev/ObjectState.git", rev = "82a6213263c9532f1b7cd4ebb2e9e368fdc5e2bd" } +formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "dc5d21e482d086c02b07dbfa5916f409487894d4" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" } diff --git a/src/toad/acp/agent.py b/src/toad/acp/agent.py index f88cae87d..d11f5e2ae 100644 --- a/src/toad/acp/agent.py +++ b/src/toad/acp/agent.py @@ -296,19 +296,16 @@ async def observe_thread_presentation(self, presentation) -> None: await self.session.observe_owner(presentation) async def update_project(self, path: str) -> str: - if (self.coordination.wire_root if self.coordination else None) is None or ( - self.coordination.thread.name if self.coordination else None - ) is None: + coordination = self.coordination + if coordination is None or coordination.wire_root is None or coordination.thread.name is None: raise ValueError("Project changes require an agent-comms thread.") - from agent_comms.comms import wire - - result = await asyncio.to_thread( - wire( - (self.coordination.wire_root if self.coordination else None) - ).threads.set_project, - (self.coordination.thread.name if self.coordination else None), - path, - ) + authority = ClientSessionRequest(self, self.session_id) + async with self.controller.transcripts.bind(coordination.wire_root) as comms: + authority.require() + result = await asyncio.to_thread( + comms.threads.set_project, coordination.thread.name, path, + ) + authority.require() self.coordination = replace(self.coordination, worktree=result.current) self.project_root_path = Path(result.current) self.events.publish(core_events.CommsUpdated(self.coordination, self.session_id)) diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index 01c3ed5a8..4a3f66b51 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -9,6 +9,7 @@ from functools import partial from agent_comms.declared_family import DeclaredFamily +from agent_comms.launch_runtime import LaunchRuntime from toad import surface_binding from toad.acp.sdk_boundary import ValidateSessionUpdateTask from toad.plan import PlanItem @@ -69,7 +70,7 @@ def __init__(self, agent): self.validation: ValidationOwner = HeadlessValidationOwner() self._deferred_submissions: set[asyncio.Task] = set() self.prompt_in_flight = 0 - self.transcripts = DetachedTranscriptReader() + self.transcripts = DetachedTranscriptReader(LaunchRuntime.running()) self.coordination = None self.session = SessionBinding(None) self.mode_state: SessionModeState | None = None diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 7128cf6af..431596bd0 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -1,10 +1,13 @@ -"""ACP session handshake, durable metadata and reconnection custody.""" +"""ACP session handshake, durable metadata and reconnection.""" from __future__ import annotations from agent_comms.declared_family import DeclaredFamily from agent_comms.child_process import join_retirement import asyncio import os +from pathlib import Path from dataclasses import dataclass, replace +from agent_comms.acp_ingress import AcpIngress +from agent_comms.route_selection import RouteSelection import toad from toad import constants, jsonrpc from toad.core import events as core_events @@ -72,6 +75,9 @@ class RefusedSessionDisposition(FailedSessionDisposition): class AgentSession: """Own readiness and durable session effects; binding identity stays on the controller.""" + + reconnect_timeout_seconds = 30 + def __init__(self, agent, pk): self.agent = agent self.task = None @@ -118,11 +124,15 @@ def start(self): self.task = asyncio.create_task(self.run()) self.task.add_done_callback(self.session_finished) - async def retire(self): + def begin_retirement(self): self.close() if self.retirement is None: self.retirement = asyncio.create_task(self._retire()) - await join_retirement(self.retirement) + self.retirement.add_done_callback(self.retirement_finished) + return self.retirement + + async def retire(self): + await join_retirement(self.begin_retirement()) async def _retire(self): pending = tuple(task for task in (self.task, *self.responses) @@ -141,8 +151,6 @@ def session_finished(self, task): error = task.exception() if error is not None and self.accepts_updates: self.startup_failed(f"{type(error).__name__}: {error}") - self.retirement = asyncio.create_task(self._retire()) - self.retirement.add_done_callback(self.retirement_finished) def retirement_finished(self, task): if task.cancelled(): @@ -161,6 +169,7 @@ def record_failure( self.failed() self.disposition = state(event) self.agent.events.publish(event) + self.begin_retirement() def session_failed(self, failure): self.record_failure( @@ -189,7 +198,7 @@ def supports_load(self): return self.capabilities.load_session def starting(self): - """Start admission clears readiness before any asynchronous work.""" + """Starting clears readiness before any asynchronous work.""" self.connected = False self.settled.clear() self.agent.controller.reset_configuration() @@ -205,7 +214,7 @@ async def touch(self): if self.pk is not None: await DB().session_update_last_used(self.pk) - def coordinated_title(self, title): + async def coordinated_title(self, title): """A coordination update presents the thread's title. A name the user chose wins: it is announced as the session's title @@ -217,7 +226,7 @@ def coordinated_title(self, title): if self.chosen_name is not None: coordination = self.agent.coordination if coordination is not None and coordination.title != self.chosen_name: - self.rename_coordination(self.chosen_name) + await self.rename_coordination(self.chosen_name) return if title: self.agent.events.publish(SessionThreadTitle(title)) @@ -277,31 +286,27 @@ async def reconnect(self, admission: SessionLoadAdmission | None = None) -> None """Reattach the existing view after login or an explicit owner start.""" if self.reconnecting: return - if self.agent.controller.session.bound and not self.supports_load: - raise ValueError("This agent cannot resume its session.") - from pathlib import Path - from agent_comms.acp_ingress import AcpIngress - from agent_comms.route_selection import RouteSelection - requested_cwd = str(self.agent.project_root_path.resolve()) self.reconnecting = True try: - try: - selection = await asyncio.to_thread( - RouteSelection.for_child, os.environ.copy(), requested_cwd) - await AcpIngress(selection, Path(requested_cwd), - self.agent.process.attached_root(self.agent)).preflight() - except Exception as error: - raise ValueError( - f"Reconnect not attempted: maintenance admission denied: {error}" - ) from error + selection = await asyncio.to_thread( + RouteSelection.for_child, os.environ.copy(), requested_cwd) + await AcpIngress(selection, Path(requested_cwd), + self.agent.process.attached_root(self.agent)).preflight() await self.agent.stop() self.load_admission = admission or EnsuringSessionLoadAdmission() self.settled.clear() await self.agent.start((self.agent.process,)) - await asyncio.wait_for(self.settled.wait(), timeout=30) - if not self.connected: - raise ValueError("The agent could not reconnect.") + try: + await asyncio.wait_for( + self.settled.wait(), timeout=self.reconnect_timeout_seconds) + except TimeoutError: + self.startup_failed( + f"Reconnect timed out after {self.reconnect_timeout_seconds} seconds " + "waiting for the session handshake to settle." + ) + except (OSError, ValueError, RuntimeError, jsonrpc.APIError) as error: + self.startup_failed(f"Reconnect failed: {type(error).__name__}: {error}") finally: self.reconnecting = False @@ -440,21 +445,22 @@ async def load(self) -> None: self.publish_configuration(response) - def rename_coordination(self, display_name: str) -> None: + async def rename_coordination(self, display_name: str) -> None: if not self.accepts_session(self.agent.session_id): return - thread = self.agent.coordination.thread.name if self.agent.coordination else None - wire_root = self.agent.coordination.wire_root if self.agent.coordination else None - if thread is None or wire_root is None: + coordination = self.agent.coordination + if coordination is None or coordination.thread.name is None or coordination.wire_root is None: return - - from agent_comms.comms import wire - - result = wire(wire_root).threads.rename_managed_thread( - thread, - display_name, - owner_pid=self.agent.coordination.owner_pid, - ) + authority = ClientSessionRequest(self.agent, self.agent.session_id) + async with self.agent.controller.transcripts.bind(coordination.wire_root) as comms: + authority.require() + result = await asyncio.to_thread( + comms.threads.rename_managed_thread, + coordination.thread.name, + display_name, + owner_pid=coordination.owner_pid, + ) + authority.require() self.agent.coordination = replace( self.agent.coordination, thread=replace(self.agent.coordination.thread, name=result.current), @@ -488,7 +494,7 @@ async def set_name(self, name: str) -> None: if self.chosen_name == name: return self.chosen_name = name or None - self.rename_coordination(name) + await self.rename_coordination(name) if self.pk is None: return db = DB() diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 58744101d..b78009642 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -118,7 +118,7 @@ def input_started(self, update: InputStartedUpdate) -> None: self.agent._post_queue_view(starts) @handles(CoordinationChangedUpdate) - def coordination_changed(self, update: CoordinationChangedUpdate) -> None: + async def coordination_changed(self, update: CoordinationChangedUpdate) -> None: from dataclasses import replace from pathlib import Path @@ -136,7 +136,7 @@ def coordination_changed(self, update: CoordinationChangedUpdate) -> None: agent.context_measurement = ContextMeasurement.saved(update.context_usage) agent.update_status_line() agent.events.publish(core_events.CommsUpdated(agent.coordination, self.session_id)) - agent.session.coordinated_title(update.title) + await agent.session.coordinated_title(update.title) @handles(GoalChangedUpdate) def goal_changed(self, update: GoalChangedUpdate) -> None: diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index e5d4eb448..b95b82d35 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -17,7 +17,9 @@ from contextlib import asynccontextmanager from pathlib import Path -from agent_comms.comms import wire +from agent_comms.active_route import resolve_comms_route +from agent_comms.comms import Comms +from agent_comms.launch_runtime import LaunchRuntime from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate from agent_comms.transcripts import TranscriptReadIdentity from agent_comms.ui_model.observation import ( @@ -28,15 +30,11 @@ class CoordinationTranscriptReader(ABC): """One agent's observed reads, and the read-side Comms service its other work binds.""" - def __init__(self): - self._reader = None - self._lock = asyncio.Lock() - @abstractmethod async def read(self, request: AwaitedRead): ... - async def service(self, root): - return await asyncio.to_thread(wire, root) + @abstractmethod + async def service(self, root) -> Comms: ... async def thread_presentation(self, root: str, thread: str): """Read one thread presentation through this reader's bound service.""" @@ -47,14 +45,7 @@ async def thread_presentation(self, root: str, thread: str): @asynccontextmanager async def bind(self, root: str): - async with self._lock: - if self._reader is None or self._reader.root != Path(root).expanduser(): - self._reader = await self.service(root) - reader = self._reader - # The lock owns service initialization/replacement, not projections. - # Each caller retains this exact root service; its canonical stores own - # read transactions and the Agent fences publication after awaits. - yield reader + yield await self.service(root) async def publication(self, update: TranscriptSnapshotUpdate) -> TranscriptSnapshotUpdate: """The published page while its witness is current, else the current page.""" @@ -90,6 +81,19 @@ async def updates( class DetachedTranscriptReader(CoordinationTranscriptReader): """An operational Agent no view has attached: nothing presents a transcript.""" + def __init__(self, runtime: LaunchRuntime): + self.runtime = runtime + self._reader: Comms | None = None + self._lock = asyncio.Lock() + + async def service(self, root): + async with self._lock: + if self._reader is None or self._reader.root != Path(root).expanduser(): + self._reader = await asyncio.to_thread( + Comms, resolve_comms_route(root), runtime=self.runtime, + ) + return self._reader + async def read(self, request): raise RuntimeError("Observed reads serve a view; no view is attached to this agent") @@ -98,14 +102,12 @@ class ObservedTranscriptReader(CoordinationTranscriptReader): """Reads answered by the application's observation service.""" def __init__(self, access): - super().__init__() self.access = access async def read(self, request): return await self.access.read(request) async def service(self, root): - shared = self.access.observed_service - if shared is not None and shared.root == Path(root).expanduser(): - return shared - return await super().service(root) + return await self.access.preparation.run_thread( + lambda: self.access.acquire(resolve_comms_route(root)), + ) diff --git a/src/toad/app.py b/src/toad/app.py index b384b98df..5be6f741e 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -12,6 +12,7 @@ import sys import json import os +from enum import IntEnum from functools import cached_property, partial from pathlib import Path from time import monotonic @@ -24,6 +25,7 @@ from textual.app import App from textual.await_complete import AwaitComplete from textual.content import Content +from textual.css.scalar import Scalar, Unit from textual.notifications import Notify from textual.reactive import var from textual.screen import Screen @@ -56,6 +58,7 @@ from toad.application_actions import ApplicationAction, ApplicationCommands from toad.widget_actions import DeclaredWidgetActions from toad.application_lifetime import ApplicationLifetime +from toad.resource_release import AsyncResourceRelease if TYPE_CHECKING: from toad.db import DB @@ -247,6 +250,21 @@ class ApplicationNotRunningError(RuntimeError): """The application has no event loop to accept cross-thread work.""" +class ScreenWidth(IntEnum): + """Native sizing modes; legacy breakpoint classes are derived from them.""" + + side_panel_width: Scalar | None + + def __new__(cls, minimum_width: int, side_panel_width: Scalar | None): + mode = int.__new__(cls, minimum_width) + mode._value_ = minimum_width + mode.side_panel_width = side_panel_width + return mode + + NARROW = (0, None) + WIDE = (100, Scalar(50, Unit.PERCENT, Unit.WIDTH)) + + class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False): """The top level app.""" @@ -260,7 +278,9 @@ class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown project_dir = var(Path) show_sessions = var(False, toggle_class="-show-sessions-bar") - HORIZONTAL_BREAKPOINTS = [(0, "-narrow"), (100, "-wide")] + HORIZONTAL_BREAKPOINTS: list[tuple[int, str]] = [ + (mode, f"-{mode.name.lower()}") for mode in ScreenWidth + ] PAUSE_GC_ON_SCROLL = True @@ -319,19 +339,18 @@ def __init__( async def _close_all(self) -> None: - await self.thread_navigation.close() - await self.thread_actions.close() - await self.coordination_access.stop_observing() - await super()._close_all() - # Native clients and the preparation owner have joined actual I/O. - # A failed retirement keeps its route resource instead of certifying exit. - await self.coordination_access.close() - - async def on_unmount(self) -> None: - self.colours.stop() - self.terminal_attention.close() - await self.navigation_reader.aclose() - await self.render_processes.aclose() + # View release can fail. The application still owns its observation + # pipe and process pools; an Unmount event may never follow that error. + async with AsyncResourceRelease() as release: + release.push_async_callback(self.render_processes.aclose) + release.push_async_callback(self.navigation_reader.aclose) + release.callback(self.terminal_attention.close) + release.callback(self.colours.stop) + release.push_async_callback(self.coordination_access.close) + release.push_async_callback(super()._close_all) + release.push_async_callback(self.coordination_access.stop_observing) + release.push_async_callback(self.thread_actions.close) + release.push_async_callback(self.thread_navigation.close) if self._background_render_tasks: await asyncio.gather(*tuple(self._background_render_tasks), return_exceptions=True) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 1eafb4baa..8dfb8ddfb 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -20,6 +20,10 @@ from weakref import WeakKeyDictionary, WeakSet from agent_comms.coordination_errors import StaleRevision +from agent_comms.active_route import CommsRoute +from agent_comms.comms import Comms +from agent_comms.launch_runtime import LaunchRuntime +from toad.acp.transcript_reader import ObservedTranscriptReader from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.private_path import FileRevision from agent_comms.route_selection import RouteChanged, RouteSelection, run_selected_write @@ -32,13 +36,11 @@ from agent_comms.ui_model.sidebar import SidebarModel, SidebarObservation from agent_comms.ui_model.status import OpenThreadStatus from toad.core.events import CoreEventStream, CoordinationObserved, ExternalTranscriptChanged, OpenTabsChanged, LinkContextChanged -from toad.forms.declared_views import DeclaredField from toad.preferences import SETTINGS from agent_comms.display_references import PendingReferences, UnavailableReferences if TYPE_CHECKING: from toad.widgets.message_notifications import HistoryNotifications - from agent_comms.comms import Comms from agent_comms.presentation import WireRevision from toad.app import ToadApp from toad.widgets.observed_thread_activity import ThreadStatusOwner @@ -102,8 +104,8 @@ class CoordinationAccess(MroDispatch): def __init__(self, app: ToadApp) -> None: self.app = app - from toad.acp.transcript_reader import ObservedTranscriptReader - + self.runtime = LaunchRuntime.running() + self.services: dict[CommsRoute, Comms] = {} self.transcripts = ObservedTranscriptReader(self) self.observation: ObservedCommsService | None = None self.events = CoreEventStream(self) @@ -113,7 +115,7 @@ def __init__(self, app: ToadApp) -> None: self.timer = None self.loop: asyncio.AbstractEventLoop | None = None self.custody = ExitStack() - self.opening = threading.Lock() + self.opening = threading.RLock() self.preparation = app.preparation self.retiring: set[asyncio.Task[None]] = set() self.marking: asyncio.Task[None] | None = None @@ -156,22 +158,30 @@ def open_current(self) -> Comms: """Capture the current route and open (or keep) its service; file I/O.""" return self.require(RouteSelection.capture()) + def acquire(self, route: CommsRoute) -> Comms: + """Keep one admitted service per resolved route for this application's lifetime.""" + with self.opening: + if route in self.services: + return self.services[route] + with ExitStack() as acquisition: + acquisition.enter_context(route.admit_client()) + route.observe_root() + service = Comms(route, runtime=self.runtime) + self.custody.enter_context(acquisition.pop_all()) + self.services[route] = service + return service + def require(self, selected: RouteSelection) -> Comms: """The service for ``selected``; a new selection opens it and starts its observation.""" - from agent_comms.comms import wire - if RouteSelection.capture() != selected: raise RouteChanged("Comms route changed before the operation") with self.opening: observed = self.observation if observed is not None and observed.selection == selected: return observed.service - with ExitStack() as acquisition: - acquisition.enter_context(selected.route.admit_client()) - service = wire(selected.route) - if service.root.resolve() != selected.root or RouteSelection.capture() != selected: - raise RouteChanged("Comms route changed while opening the service") - self.custody.enter_context(acquisition.pop_all()) + service = self.acquire(selected.route) + if service.root.resolve() != selected.root or RouteSelection.capture() != selected: + raise RouteChanged("Comms route changed while opening the service") # Textual runs the models' flushes after the current message, # before the next frame is composed. self.observation = ObservedCommsService( @@ -332,8 +342,8 @@ def sidebar_request(self) -> ObserveSidebar: state = self.app.saved_settings.state return ObserveSidebar( str(self.app.project_dir), - DeclaredField.at(state, SETTINGS.sidebar.show_stopped).value, - DeclaredField.at(state, SETTINGS.sidebar.show_archived).value, + SETTINGS.sidebar.show_stopped.bind(state).value, + SETTINGS.sidebar.show_archived.bind(state).value, ) def update_sidebar_interest(self) -> None: @@ -432,6 +442,7 @@ async def close(self) -> None: self.observation = None await asyncio.gather(*self.retiring, return_exceptions=True) self.custody.close() + self.services.clear() def current_route_stamp(self) -> tuple[FileRevision | None, ...]: from agent_comms.active_route import active_route_path diff --git a/src/toad/core/preference_events.py b/src/toad/core/preference_events.py index cf01fe1be..d3fb98c38 100644 --- a/src/toad/core/preference_events.py +++ b/src/toad/core/preference_events.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from toad.forms.field_path import FieldPath +from toad.forms.declared_views import DeclaredField from .events import CoreEvent @@ -10,4 +10,4 @@ class PreferenceChanged(CoreEvent): """Read this setting again; what the app does on the change has been done.""" - path: FieldPath + field: DeclaredField diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 5ee144919..58c6a76f9 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -3,17 +3,25 @@ from __future__ import annotations from collections.abc import Iterable, Iterator -from dataclasses import MISSING, dataclass, replace +from dataclasses import dataclass from inspect import cleandoc -from typing import Annotated, ClassVar, Optional, Self, get_args, get_origin - -from objectstate import ObjectState -from python_introspect import optional_member_type +from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin + +from formbridge.forms.ui_utils import FieldDisplayText +from objectstate import DottedFieldPath, ObjectState +from python_introspect import ( + ParameterInfo, + declared_field_label, + optional_member_type, + resolve_annotated, + resolve_optional, +) from python_introspect.validation import validate_annotation_value -from toad.forms.field_path import FieldPath, declared, declared_annotation, declared_field, label from toad.forms.kinds import Placeholder +Metadata = TypeVar("Metadata") + class DeclaredViews: """A family of widget classes, each registered under what it shows. @@ -23,8 +31,9 @@ class DeclaredViews: of field is adding a class: nothing lists the members and nothing chooses between them by testing types. - Textual reads a widget's focus and CSS from its first base, so a member - names its Textual class first and its family after it. + Textual follows the first DOM base for native focus and CSS. A pure editor + capability may precede that base so its cooperative initializer binds the + field before the native control consumes it. """ shows: ClassVar[tuple[object, ...]] = () @@ -70,43 +79,57 @@ class DeclaredField: """One field of an ObjectState: where its value lives and how it was declared.""" state: ObjectState - path: FieldPath - annotation: object + path: DottedFieldPath - @classmethod - def at(cls, state: ObjectState, path: FieldPath) -> DeclaredField: - return cls(state, path, declared_annotation(path)) + @property + def declaration(self) -> ParameterInfo: + return self.state.parameter_structure.declarations[self.path.value] + + @property + def annotation(self) -> object: + return self.declaration.param_type + + def metadata(self, kind: type[Metadata]) -> Metadata | None: + """Metadata of the admitted field, including an optional member's metadata.""" + for annotation in (self.annotation, resolve_optional(resolve_annotated(self.annotation))): + if get_origin(annotation) is Annotated: + for item in get_args(annotation)[1:]: + if isinstance(item, kind): + return item + return None @property def value(self) -> object: - return self.state.get_resolved_value(str(self.path)) + return self.state.get_resolved_value(self.path.value) @property def default(self) -> object: - return self.state.signature_default(str(self.path)) + return self.declaration.default_value @property def has_default(self) -> bool: - field = declared_field(self.path) - return field.default is not MISSING or field.default_factory is not MISSING + return not self.declaration.is_required @property def placeholder(self) -> str: - hint = declared(self.path, Placeholder) + hint = self.metadata(Placeholder) return hint.text if hint is not None else "" @property def label(self) -> str: - return label(self.path) + return ( + declared_field_label(self.annotation) + or FieldDisplayText.from_field_name(self.declaration.name).display_name + ) @property def help(self) -> str: - return cleandoc(self.state.parameter_descriptions.get(str(self.path)) or "") + return cleandoc(self.declaration.description or "") @property def member(self) -> DeclaredField: """The same field as declared without `None`.""" - return replace(self, annotation=optional_member_type(self.annotation)) + return OptionalMemberField(self.state, self.path) def edit(self, value: object) -> None: """Give the field a value, unless its declaration does not accept it. @@ -117,4 +140,12 @@ def edit(self, value: object) -> None: # ObjectState takes any value for a field of the root object and only # fails later, when the object is rebuilt (ObjectState issue #47). validate_annotation_value(self.annotation, value, path=self.label) - self.state.update_parameter(str(self.path), value) + self.state.update_parameter(self.path.value, value) + + +class OptionalMemberField(DeclaredField): + """The member editor reads a narrower annotation, not a copied declaration.""" + + @property + def annotation(self) -> object: + return optional_member_type(super().annotation) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index e8965e2a3..23c94fe5f 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -16,12 +16,16 @@ from textual import containers from textual.app import ComposeResult from textual.validation import Function +from textual.geometry import Spacing +from textual.css.scalar import Scalar +from textual.color import TRANSPARENT from textual.widget import Widget from textual.widgets import Checkbox, Input, Select, TextArea from textual.widgets._input import InputType from toad.forms.declared_views import DeclaredField, DeclaredViews, declarations from toad.forms.kinds import Multiline +from toad.forms.presentation import FormCheckbox, FormInput, FormPart, FormSelectCurrent, FramedControl if TYPE_CHECKING: EditorWidget = Widget @@ -33,8 +37,8 @@ class ValueEditor(DeclaredViews, EditorWidget): """A control that shows one field's value and sends the user's edits to the state. The value lives in the ObjectState: the control is painted from it and - keeps nothing else. Each member is a Textual control first and an editor - second: `class BoolEditor(Checkbox, ValueEditor)`. + keeps nothing else. The editor capability binds the field before the native + control is initialized: `class BoolEditor(ValueEditor, Checkbox)`. """ states_default: ClassVar[bool] = True @@ -42,6 +46,19 @@ class ValueEditor(DeclaredViews, EditorWidget): field: DeclaredField + def __init__(self, field: DeclaredField) -> None: + self.field = field + super().__init__(**self.control_arguments()) + self.bind_control() + + def control_arguments(self) -> dict[str, object]: + """Arguments for the native control; containers need no initial value.""" + return {} + + def bind_control(self) -> None: + """Connect the native control's edits after it has been initialized.""" + raise NotImplementedError + @classmethod def declared_for(cls, field: DeclaredField) -> ValueEditor: return cls.showing(declarations(field.annotation))(field) @@ -68,12 +85,13 @@ def refuse(self, error: Exception) -> None: self.paint() -class BoolEditor(Checkbox, ValueEditor): +class BoolEditor(ValueEditor, FormCheckbox): shows = (bool,) - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__(value=bool(field.value)) + def control_arguments(self) -> dict[str, object]: + return {"value": bool(self.field.value)} + + def bind_control(self) -> None: self.listen(Checkbox.Changed, self.changed) def changed(self, message: Checkbox.Changed) -> None: @@ -85,16 +103,19 @@ def paint(self) -> None: self.value = bool(self.field.value) -class LineEditor(Input, ValueEditor): +class LineEditor(ValueEditor, FormInput): """One line of text, read as the field's type when the user leaves it or presses enter.""" accepts: ClassVar[InputType] - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__( - self.value_text(field.value), type=self.accepts, validators=[Function(self.acceptable)] - ) + def control_arguments(self) -> dict[str, object]: + return { + "value": self.value_text(self.field.value), + "type": self.accepts, + "validators": [Function(self.acceptable)], + } + + def bind_control(self) -> None: self.listen(Input.Blurred, self.finished) self.listen(Input.Submitted, self.finished) @@ -143,20 +164,18 @@ class StrEditor(LineEditor): accepts = "text" -class MultilineEditor(TextArea, ValueEditor): +class MultilineEditor(ValueEditor, FramedControl, TextArea): shows = (Multiline,) - - DEFAULT_CSS = """ - MultilineEditor { - height: 10; - } - """ + height = Scalar.from_number(10) + padding = Spacing(0, 1, 0, 1) + background_tint = TRANSPARENT states_default = False - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__(str(field.value or "")) + def control_arguments(self) -> dict[str, object]: + return {"text": str(self.field.value or "")} + + def bind_control(self) -> None: self.listen(TextArea.Changed, self.changed) def changed(self, message: TextArea.Changed) -> None: @@ -170,20 +189,28 @@ def paint(self) -> None: self.load_text(value) -class SelectEditor(Select[object], ValueEditor): +class SelectEditor(ValueEditor, FormPart, Select[object]): """One of a list of values. A value that is not on the list shows as its own text.""" - def __init__(self, field: DeclaredField) -> None: - self.field = field + CURRENT = FormSelectCurrent + + def watch_has_focus(self, has_focus: bool) -> None: + super().watch_has_focus(has_focus) + if self.is_mounted: + self.query_one(FormSelectCurrent).refresh_paint() + + def control_arguments(self) -> dict[str, object]: self.offered = tuple(self.options()) - value = field.value + value = self.field.value listed = self.lists(value) - super().__init__( - self.offered, - value=value if listed else Select.NULL, - allow_blank=not listed, - prompt=self.value_text(value), - ) + return { + "options": self.offered, + "value": value if listed else Select.NULL, + "allow_blank": not listed, + "prompt": self.value_text(value), + } + + def bind_control(self) -> None: self.listen(Select.Changed, self.changed) def options(self) -> Iterable[tuple[str, object]]: @@ -232,18 +259,16 @@ def options(self) -> Iterable[tuple[str, object]]: return ((format_enum_display(member), member) for member in members) -class OptionalEditor(containers.VerticalGroup, ValueEditor): +class OptionalEditor(ValueEditor, FormPart, containers.VerticalGroup): """A field that may hold nothing: a box says whether it holds a value, and the editor of that value is shown while it does.""" shows = (Optional,) - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__() - self.holds_value = Checkbox("Set", value=field.value is not None) - self.member = ValueEditor.declared_for(field.member) - self.member.display = field.value is not None + def bind_control(self) -> None: + self.holds_value = FormCheckbox("Set", value=self.field.value is not None) + self.member = ValueEditor.declared_for(self.field.member) + self.member.display = self.field.value is not None self.holds_value.listen(Checkbox.Changed, self.held_changed) def compose(self) -> ComposeResult: diff --git a/src/toad/forms/field_path.py b/src/toad/forms/field_path.py index 710e4f690..8d7c01d55 100644 --- a/src/toad/forms/field_path.py +++ b/src/toad/forms/field_path.py @@ -2,15 +2,14 @@ from __future__ import annotations -from dataclasses import Field, fields, is_dataclass -from functools import lru_cache -from typing import Annotated, TypeVar, get_args, get_origin +from dataclasses import fields, is_dataclass +from typing import TYPE_CHECKING -from formbridge.forms.ui_utils import FieldDisplayText -from python_introspect import declared_field_label, resolve_annotated, resolve_optional -from python_introspect.annotation_types import resolved_class_annotations +if TYPE_CHECKING: + from objectstate import ObjectState + from toad.forms.declared_views import DeclaredField -Declared = TypeVar("Declared") +from python_introspect import UnifiedParameterAnalyzer, resolve_annotated, resolve_optional class FieldPath: @@ -47,11 +46,20 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self._root, self._names)) + def bind(self, state: ObjectState) -> DeclaredField: + """Bind a declaration reference through ObjectState's admitted topology.""" + from toad.forms.declared_views import DeclaredField -@lru_cache(maxsize=None) -def _annotations(owner: type) -> dict[str, object]: - # Declarations do not change while the process runs. - return resolved_class_annotations(owner) + if not issubclass(state.type_for_path(""), self._root): + raise TypeError(f"{self!r} does not name a field of this ObjectState") + return DeclaredField(state, state.parameter_structure.path(str(self))) + + def matches(self, field: DeclaredField) -> bool: + """Whether this declaration names the bound field, including its root type.""" + return ( + issubclass(field.state.type_for_path(""), self._root) + and field.path.parts == self._names + ) def parent(path: FieldPath) -> FieldPath: @@ -62,57 +70,11 @@ def declared_annotation(path: FieldPath) -> object: """The field's annotation as written, `Annotated` metadata included.""" if not path._names: return path._root - return _annotations(declared_type(parent(path)))[path._names[-1]] + return UnifiedParameterAnalyzer.analyze(declared_type(parent(path)))[ + path._names[-1] + ].param_type def declared_type(path: FieldPath) -> object: """The type a value of the field has, without `Annotated` and `Optional`.""" return resolve_annotated(resolve_optional(resolve_annotated(declared_annotation(path)))) - - -def declared_field(path: FieldPath) -> Field[object]: - """The dataclass field the path names, with the metadata it was declared with.""" - name = path._names[-1] - return next(field for field in fields(declared_type(parent(path))) if field.name == name) - - -def declared(path: FieldPath, kind: type[Declared]) -> Declared | None: - """The `Annotated` metadata of one kind declared on the field, if any.""" - annotation = declared_annotation(path) - for written in (annotation, resolve_optional(resolve_annotated(annotation))): - if get_origin(written) is Annotated: - for item in get_args(written)[1:]: - if isinstance(item, kind): - return item - return None - - -def label(path: FieldPath) -> str: - """What the field is called on screen: its declared label, or its name spelled out.""" - return ( - declared_field_label(declared_annotation(path)) - or FieldDisplayText.from_field_name(path._names[-1]).display_name - ) - - -def steps(path: FieldPath) -> tuple[FieldPath, ...]: - """The fields passed on the way down to this one, itself last.""" - return tuple( - FieldPath(path._root, path._names[:count]) for count in range(1, len(path._names) + 1) - ) - - -def fields_below(path: FieldPath) -> tuple[FieldPath, ...]: - """The fields of the dataclass this path holds; none when it holds a plain value.""" - owner = declared_type(path) - if not is_dataclass(owner): - return () - return tuple(getattr(path, field.name) for field in fields(owner)) - - -def leaves(path: FieldPath) -> tuple[FieldPath, ...]: - """Every plain-valued field at or below this path.""" - below = fields_below(path) - if not below: - return (path,) - return tuple(leaf for child in below for leaf in leaves(child)) diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 26c5aaaba..d74f63dfe 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -4,93 +4,72 @@ from collections.abc import Iterator -from formbridge.forms.parameter_form_service import ( - FormStructure, - ParameterAnalysisInput, - ParameterFormService, -) -from formbridge.forms.parameter_info_types import DirectDataclassInfo, GenericInfo -from objectstate import ObjectState -from python_introspect import UnifiedParameterAnalyzer, resolve_annotated +from formbridge.forms.parameter_form_service import ParameterFormService +from formbridge.forms.parameter_info_types import DirectDataclassInfo, ParameterInfo +from objectstate import DottedFieldPath, ObjectState from textual import containers, events, lazy from textual.app import ComposeResult from textual.content import Content from textual.widgets import Static +from textual.css._style_properties import Edges +from textual.geometry import Spacing +from textual.layouts.stream import StreamLayout +from rich.style import Style +from ui_theme.scheme import BackgroundColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour from toad.forms.declared_views import DeclaredField, DeclaredViews from toad.forms.editors import ValueEditor -from toad.forms.field_path import FieldPath, label, steps +from toad.forms.field_path import FieldPath +from toad.forms.presentation import AUTO, FormPart -def form_structure(state: ObjectState) -> FormStructure: - """What formbridge finds in the object the state holds. - - formbridge is given each field's type without its `Annotated` metadata: - it does not see a nested dataclass through `Annotated` (formbridge issue #7). - The views read labels, choices and bounds from the declaration itself. - """ - held = state.object_instance - declared = UnifiedParameterAnalyzer.analyze(held) - return ParameterFormService().analyze_parameters( - ParameterAnalysisInput( - default_value={name: state.parameters[name] for name in declared}, - param_type={ - name: resolve_annotated(parameter.param_type) - for name, parameter in declared.items() - }, - field_id="", - description=state.parameter_descriptions, - parent_obj_type=type(held), - ) - ) +class FieldTitle(FormPart, Static): + padding = Spacing(0, 0, 0, 1) + text_style = Style(bold=True) -class FieldTitle(Static): - DEFAULT_CSS = """ - FieldTitle { - padding: 0 0 0 1; - text-style: bold; - } - """ +class FieldHelp(FormPart, Static): + padding = Spacing(0, 0, 0, 1) + margin = Spacing(0, 0, 1, 0) + @property + def paint_foreground(self): + return self.app.colours.colour(MutedColour) -class FieldHelp(Static): - DEFAULT_CSS = """ - FieldHelp { - color: $text-muted; - padding: 0 0 0 1; - margin-bottom: 1; - } - """ +class GroupTitle(FormPart, Static): + padding = Spacing(0, 0, 0, 1) -class GroupTitle(Static): - DEFAULT_CSS = """ - GroupTitle { - padding: 0 0 0 1; - color: $primary; - } - """ + @property + def paint_foreground(self): + return self.app.colours.colour(PrimaryColour) -class FieldView(containers.VerticalGroup, DeclaredViews): +class FieldView(FormPart, containers.VerticalGroup, DeclaredViews): """One parameter of a form, shown the way its formbridge kind asks for.""" - def __init__(self, field: DeclaredField, structure: FormStructure, - included: frozenset[FieldPath] | None = None) -> None: + def __init__(self, field: DeclaredField, + included: frozenset[DottedFieldPath] | None = None) -> None: super().__init__() self.field = field + @property + def margin(self) -> Spacing: + return Spacing(0, 0, 0 if self.last_child else 1, 0) + @staticmethod - def of(state: ObjectState, parent: FieldPath, structure: FormStructure, - included: frozenset[FieldPath] | None = None) -> list[FieldView]: - """A view for each parameter formbridge lists, in its order.""" + def of(state: ObjectState, parent: str = "", + included: frozenset[DottedFieldPath] | None = None) -> list[FieldView]: + """Show ObjectState's direct children; FormBridge supplies each field's kind. + + ObjectState already extracted this topology. Reading it does not rebuild + a second form tree or re-analyze the held object's values. + """ views = [] - for parameter in structure.parameters: - path = getattr(parent, parameter.name) + for path, parameter in ParameterFormService().parameters_for_state(state, parent): if included is None or path in included: - field = DeclaredField.at(state, path) - views.append(FieldView.showing(type(parameter).__mro__)(field, structure, included)) + field = DeclaredField(state, path) + views.append(FieldView.showing(type(parameter).__mro__)(field, included)) return views def values(self) -> Iterator[ValueView]: @@ -105,25 +84,19 @@ def filter(self, term: str) -> bool: class ValueView(FieldView): """A field with one value: its label, its help and the editor for its kind.""" - shows = (GenericInfo,) - - DEFAULT_CSS = """ - ValueView { - margin-bottom: 1; - &:last-child { - margin-bottom: 0; - } - } - """ + shows = (ParameterInfo,) CHANGED_MARK = " ●" - def __init__(self, field: DeclaredField, structure: FormStructure, - included: frozenset[FieldPath] | None = None) -> None: - super().__init__(field, structure, included) + def __init__(self, field: DeclaredField, + included: frozenset[DottedFieldPath] | None = None) -> None: + super().__init__(field, included) self.editor = ValueEditor.declared_for(field) self.title = FieldTitle(field.label) - self.search_text = " ".join(label(step) for step in steps(field.path)).lower() + self.search_text = " ".join( + DeclaredField(field.state, step).label + for step in field.state.parameter_structure.ancestors(field.path) + ).lower() def compose(self) -> ComposeResult: yield self.title @@ -158,28 +131,20 @@ class GroupView(FieldView): shows = (DirectDataclassInfo,) - DEFAULT_CSS = """ - GroupView { - border: tall $secondary-muted; - padding: 0 1; - margin-bottom: 1; - &:light { - border: tall $foreground 20%; - } - &:focus-within { - border: tall $secondary; - } - &:last-child { - margin-bottom: 0; - } - } - """ + padding = Spacing(0, 1, 0, 1) + + @property + def border(self) -> Edges: + colours = self.app.colours + colour = (colours.colour(SecondaryColour) if self.has_focus_within else + colours.mixed(BackgroundColour, SecondaryColour, 0.3) if colours.scheme.dark else + colours.colour(ForegroundColour).multiply_alpha(0.2)) + return Edges(*(("tall", colour),) * 4) - def __init__(self, field: DeclaredField, structure: FormStructure, - included: frozenset[FieldPath] | None = None) -> None: - super().__init__(field, structure, included) - name = str(field.path).rpartition(".")[2] - self.parts = FieldView.of(field.state, field.path, structure.nested_forms[name], included) + def __init__(self, field: DeclaredField, + included: frozenset[DottedFieldPath] | None = None) -> None: + super().__init__(field, included) + self.parts = FieldView.of(field.state, field.path.value, included) def compose(self) -> ComposeResult: yield GroupTitle(self.field.label) @@ -195,7 +160,7 @@ def filter(self, term: str) -> bool: return bool(self.display) -class FormView(containers.VerticalScroll, can_focus=False): +class FormView(FormPart, containers.VerticalScroll, can_focus=False): """Every field of the object an ObjectState holds, edited in place. The form holds no values. An edit goes to the state; the state says which @@ -204,27 +169,24 @@ class FormView(containers.VerticalScroll, can_focus=False): long form does not hold up the frame it opens in. """ - DEFAULT_CSS = """ - FormView { - layout: stream; - overflow: hidden scroll; - } - """ + DEFAULT_LAYOUT = StreamLayout + height = None + overflow_y = "scroll" def __init__(self, state: ObjectState, *, paths: tuple[FieldPath, ...] | None = None, - focus: FieldPath | None = None) -> None: + focus: DottedFieldPath | None = None) -> None: super().__init__() self.state = state - included = (None if paths is None else - frozenset(step for path in paths for step in steps(path))) - self.parts = FieldView.of( - state, FieldPath(type(state.object_instance)), form_structure(state), included - ) + included = (None if paths is None else frozenset( + step for path in paths + for step in state.parameter_structure.ancestors(path.bind(state).path) + )) + self.parts = FieldView.of(state, included=included) self.values = { - str(view.field.path): view for part in self.parts for view in part.values() + view.field.path.value: view for part in self.parts for view in part.values() } if focus is not None: - editor = self.values[str(focus)].editor + editor = self.values[focus.value].editor editor.listen(events.Mount, lambda _: editor.focus()) self.listen(events.Mount, self.follow_state) self.listen(events.Unmount, self.leave_state) @@ -257,3 +219,9 @@ def filter(self, term: str) -> None: """Show only the fields whose labels contain the term.""" for part in self.parts: part.filter(term.lower()) + + +class InlineFormView(FormView): + """An intrinsically sized form inside a bounded dialog panel.""" + + height = AUTO diff --git a/src/toad/forms/presentation.py b/src/toad/forms/presentation.py new file mode 100644 index 000000000..d3a51f1ea --- /dev/null +++ b/src/toad/forms/presentation.py @@ -0,0 +1,175 @@ +"""Native form geometry and paint, supplied directly to Textual's owners.""" +from __future__ import annotations + +from rich.style import NULL_STYLE +from textual import events +from textual._paint_state import PaintLayer +from textual._styles_cache import FramePaint +from textual.box_model import BoxGeometry +from textual.color import Color, TRANSPARENT +from textual.css._style_properties import Edges +from textual.css.scalar import Scalar, ScalarOffset, Unit +from textual.geometry import Spacing +from textual.layouts.horizontal import HorizontalLayout +from textual.widgets._select import SelectCurrent +from textual.widgets import Checkbox, Input +from ui_theme.scheme import BackgroundColour, ErrorColour, ForegroundColour, PrimaryColour, SurfaceColour + + +AUTO = Scalar(1.0, Unit.AUTO, Unit.AUTO) + + +class FormPart(BoxGeometry, PaintLayer, FramePaint): + """The widget itself supplies native inputs; there is no styles mirror. + + Mutable state remains on the native control. Non-versioned paint inputs + are read live instead of being cached under an unrelated CSS generation. + The existing line cache still retains rendered strips and checks its inputs. + """ + + width = None + height = AUTO + min_width = min_height = max_width = max_height = None + padding = margin = Spacing(0, 0, 0, 0) + border = outline = Edges(*(("", TRANSPARENT),) * 4) + box_sizing = "border-box" + width_policy = "greedy" + dock = split = "none" + overflow_x = overflow_y = "hidden" + scrollbar_policy = "auto" + vertical_scrollbar_size = 2 + horizontal_scrollbar_size = 1 + scrollbar_visibility = "visible" + align_horizontal = "left" + align_vertical = "top" + grid_rows = grid_columns = () + grid_size_columns = 1 + grid_size_rows = grid_gutter_horizontal = grid_gutter_vertical = 0 + row_span = column_span = 1 + keyline = ("none", TRANSPARENT) + position = "relative" + overlay = constrain_x = constrain_y = "none" + child_layout = None + placement_offset = None + layer_name = "default" + layer_order = None + + paint_foreground = paint_background = None + background_tint = tint = TRANSPARENT + text_style = NULL_STYLE + text_opacity = layer_opacity = 1.0 + auto_color = False + paint_key = None + paint_cacheable = False + hatch = None + border_title_align = "left" + border_subtitle_align = "right" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.listen(events.DescendantFocus, self.focus_changed) + self.listen(events.DescendantBlur, self.focus_changed) + + @property + def geometry(self) -> BoxGeometry: + return self + + @property + def paint_layer(self) -> PaintLayer: + return self + + @property + def frame_paint(self) -> FramePaint: + return self + + @property + def paint_gutter(self) -> Spacing: + return self.box_gutter + + def set_placement_offset(self, offset: ScalarOffset) -> None: + self.placement_offset = offset + self.refresh(layout=True) + + def focus_changed(self, event) -> None: + self.refresh_paint() + + +class FramedControl(FormPart): + height = Scalar.from_number(3) + padding = Spacing(0, 2, 0, 2) + horizontal_scrollbar_size = 0 + + @property + def control_focused(self) -> bool: + return self.has_focus or self.has_focus_within + + @property + def border_colour(self) -> Color: + return (self.app.colours.colour(PrimaryColour) if self.control_focused + else Color(0, 0, 0, 0.2)) + + @property + def border(self) -> Edges: + return Edges(*(("tall", self.border_colour),) * 4) + + @property + def background_tint(self) -> Color: + return (self.app.colours.colour(ForegroundColour).multiply_alpha(0.05) + if self.control_focused else TRANSPARENT) + + @property + def paint_background(self) -> Color: + return self.app.colours.colour(SurfaceColour) + + +class FormInput(FramedControl, Input): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.listen(Input.Changed, self.validation_changed) + + def validation_changed(self, message: Input.Changed) -> None: + self.refresh_paint() + + @property + def border_colour(self) -> Color: + return (super().border_colour if self.is_valid else + self.app.colours.colour(ErrorColour).multiply_alpha(0.6)) + + +class FormCheckbox(FramedControl, Checkbox): + width = AUTO + padding = Spacing(0, 1, 0, 1) + + +class FormSelectCurrent(FramedControl, SelectCurrent): + """The existing Select owns interaction; its current-value child owns its frame.""" + + DEFAULT_LAYOUT = HorizontalLayout + height = AUTO + + @property + def control_focused(self) -> bool: + return self.parent.has_focus or self.parent.has_focus_within + + +class FormModal(FormPart): + """Shared native modal presentation and theme invalidation lifecycle.""" + + height = None + align_horizontal = "center" + align_vertical = "middle" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.listen(events.Mount, self.follow_theme) + + @property + def paint_background(self) -> Color: + return self.app.colours.colour(BackgroundColour).multiply_alpha(0.6) + + def follow_theme(self, event: events.Mount) -> None: + self.app.theme_changed_signal.subscribe(self, self.theme_changed) + + def theme_changed(self, theme) -> None: + # A theme changes the entire visible modal, not each offscreen field. + self.refresh() diff --git a/src/toad/preferences.py b/src/toad/preferences.py index 40de36100..bc67bdd37 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -18,19 +18,13 @@ from agent_comms.field_codec import FieldCodec from annotated_types import Ge -from objectstate import ObjectState, set_base_config_type +from objectstate import DottedFieldPath, ObjectState, set_base_config_type from python_introspect import AnnotatedDataclassValidationMixin, FieldLabel from toad import atomic, paths from toad import setting_effects as effects from toad.forms.declared_views import DeclaredField -from toad.forms.field_path import ( - FieldPath, - declared, - declared_field, - fields_below, - leaves, -) +from toad.forms.field_path import FieldPath from toad.forms.kinds import Multiline from toad.setting_choices import ( AlwaysSessionBar, @@ -327,7 +321,7 @@ def raise_save_error(error: atomic.AtomicWriteError) -> None: raise error -def no_effect(path: FieldPath, value: object) -> None: +def no_effect(field: DeclaredField) -> None: """Settings held outside an app change nothing but themselves.""" @@ -345,7 +339,7 @@ def __init__( raw: object = None, *, root: type[ToadSettings] = ToadSettings, - applied: Callable[[FieldPath, object], None] = no_effect, + applied: Callable[[DeclaredField], None] = no_effect, report_error: Callable[[atomic.AtomicWriteError], None] = raise_save_error, ) -> None: from toad.core.events import CoreEventStream @@ -353,8 +347,7 @@ def __init__( raw = {} if raw is None else raw self.root = FieldPath(root) self.state = ObjectState(FieldCodec.decode(root, raw)) - self.named = {str(path): path for path in leaves(self.root)} - self.in_file = frozenset(self.keys_held(self.root, raw)) + self.in_file = frozenset(self.keys_held("", raw)) self.user_set = set(self.in_file) self.applied, self.report_error = applied, report_error self.events = CoreEventStream(self) @@ -369,20 +362,24 @@ def current(self) -> ToadSettings: return self.state.to_object() def set(self, path: FieldPath, value: object) -> None: - DeclaredField.at(self.state, path).edit(value) + setting = path.bind(self.state) + setting.edit(value) + self.user_set.add(setting.path) def toggle(self, path: FieldPath) -> None: - setting = DeclaredField.at(self.state, path) + setting = path.bind(self.state) setting.edit(not setting.value) def changed(self, changed: set[str]) -> None: from toad.core.preference_events import PreferenceChanged - for name in changed & self.named.keys(): - path = self.named[name] - self.user_set.add(path) - self.applied(path, self.state.get_resolved_value(name)) - self.events.publish(PreferenceChanged(path)) + for name in changed: + if (name in self.state.parameter_structure.declarations + and not self.state.has_parameter_descendants(name)): + setting = DeclaredField(self.state, self.state.parameter_structure.path(name)) + self.user_set.add(setting.path) + self.applied(setting) + self.events.publish(PreferenceChanged(setting)) def discard(self) -> None: """Go back to what the file holds.""" @@ -395,28 +392,28 @@ def discard(self) -> None: def file_path() -> Path: return paths.get_config() / "toad.json" - @staticmethod - def saved_key(path: FieldPath) -> str: - declaration = declared_field(path) - return declaration.metadata.get("wire_name", declaration.name) + def saved_key(self, path: DottedFieldPath) -> str: + owner = self.state.type_for_path(path.parent.value) + return next(key for declaration, key in FieldCodec.record_fields(owner) + if declaration.name == path.field_name) - def keys_held(self, path: FieldPath, raw: object) -> list[FieldPath]: + def keys_held(self, path: str, raw: object) -> list[DottedFieldPath]: """The settings and groups a document read from the file has a key for.""" if not isinstance(raw, dict): return [] held = [] - for below in fields_below(path): + for below in self.state.direct_parameter_paths(path): if (key := self.saved_key(below)) in raw: - held += [below, *self.keys_held(below, raw[key])] + held += [below, *self.keys_held(below.value, raw[key])] return held - def kept(self, path: FieldPath, encoded: dict[str, Any]) -> dict[str, Any]: + def kept(self, path: str, encoded: dict[str, Any]) -> dict[str, Any]: """The part of the whole encoded tree that belongs in the file.""" document = {} - for below in fields_below(path): + for below in self.state.direct_parameter_paths(path): key = self.saved_key(below) - if fields_below(below): - part = self.kept(below, encoded[key]) + if self.state.has_parameter_descendants(below.value): + part = self.kept(below.value, encoded[key]) if part or below in self.user_set: document[key] = part elif below in self.user_set: @@ -424,7 +421,7 @@ def kept(self, path: FieldPath, encoded: dict[str, Any]) -> dict[str, Any]: return document def document(self) -> dict[str, Any]: - return self.kept(self.root, FieldCodec.encode(self.current)) + return self.kept("", FieldCodec.encode(self.current)) @property def json(self) -> str: @@ -476,9 +473,9 @@ def open(cls, app: ToadApp) -> SavedSettings: path = cls.file_path() raw = json.loads(path.read_text("utf-8")) if path.exists() else {} - def applied(setting: FieldPath, value: object) -> None: - if (effect := declared(setting, effects.Effect)) is not None: - effect.apply(app, value) + def applied(field: DeclaredField) -> None: + if (effect := field.metadata(effects.Effect)) is not None: + effect.apply(app, field.value) def report(error: atomic.AtomicWriteError) -> None: app.notify(str(error), title="Settings", severity="error") @@ -487,8 +484,8 @@ def report(error: atomic.AtomicWriteError) -> None: def apply_all(self) -> None: """Bring a starting app in line with every setting.""" - for name, path in self.named.items(): - self.applied(path, self.state.get_resolved_value(name)) + for path in self.state.parameter_structure.leaf_paths(): + self.applied(DeclaredField(self.state, path)) def ensure_file(self, app: ToadApp) -> None: path = self.file_path() diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index cd41ea4c7..17e14818f 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -383,14 +383,14 @@ async def close_presentation(self) -> None: self.content.close(self) await self.presentation.close(self) - def on_comms_session_named(self, thread_name: str) -> None: + async def on_comms_session_named(self, thread_name: str) -> None: """Tell the sidebar which thread is this screen's session.""" previous = self._comms_thread self._comms_thread = thread_name if (sidebar := self.query_one_optional(CommsSidebar)) is not None: sidebar.session_thread = thread_name if self.id is not None: - self.app.session_navigation.sync_identity(self.id, previous, thread_name) + await self.app.session_navigation.sync_identity(self.id, previous, thread_name) self.app.session_tracker.bind_identity(self.id, previous, thread_name) self._sync_thread_sidebar() if self.id is not None: @@ -442,7 +442,7 @@ async def on_coordination_update(self, event: CoordinationChangedUpdate) -> None self.link_context_changed() if event.worktree is not None: self.bind_project(Path(event.worktree)) - self.on_comms_session_named(event.thread.name) + await self.on_comms_session_named(event.thread.name) await self.presentation.apply_coordination(self, event) @handles(core_events.AgentReady) diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 87c4572d3..0a602ca10 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -9,10 +9,13 @@ from textual.binding import CallBinding from toad.screens.modal import ModalScreen from textual.widgets import Footer, Input +from textual.geometry import Spacing +from textual.color import Color -from toad.app import ToadApp +from toad.app import ScreenWidth, ToadApp from toad.forms.editors import SelectEditor from toad.forms.form_view import FormView +from toad.forms.presentation import FormInput, FormModal, FormPart from toad.setting_choices import SchemeName @@ -26,21 +29,33 @@ def options(self) -> Iterable[tuple[str, object]]: return ((name, name) for name in self.app.colours.schemes) -class SettingsPanel(containers.Vertical): +class SettingsPanel(FormPart, containers.Vertical): """The side of the screen the settings are shown in.""" + height = None + padding = Spacing(0, 1, 0, 1) + paint_background = Color(0, 0, 0, 0.1) -class SettingsSearch(containers.VerticalGroup): + @property + def width(self): + return ScreenWidth(self.screen.horizontal_breakpoint).side_panel_width + + +class SettingsSearch(FormPart, containers.VerticalGroup): """Stays above the settings while they scroll.""" + dock = "top" + padding = Spacing(1, 0, 1, 0) + -class SettingsScreen(ModalScreen[None]): - CSS_PATH = "settings.tcss" +class SettingsScreen(FormModal, ModalScreen[None]): + align_horizontal = "right" + align_vertical = "top" app = getters.app(ToadApp) def __init__(self) -> None: super().__init__() - self.search = Input(placeholder="Search settings") + self.search = FormInput(placeholder="Search settings") self.form = FormView(self.app.saved_settings.state) self.search.listen(Input.Changed, self.searched) self.listen(events.Mount, self.opened) diff --git a/src/toad/screens/settings.tcss b/src/toad/screens/settings.tcss deleted file mode 100644 index 2b2668356..000000000 --- a/src/toad/screens/settings.tcss +++ /dev/null @@ -1,35 +0,0 @@ -SettingsScreen { - overflow: hidden; - background: $background 60%; - align-horizontal: right; - - &.-narrow SettingsPanel { - width: 1fr; - } - - SettingsPanel { - width: 50%; - padding: 0 1; - background: black 10%; - } - - SettingsSearch { - dock: top; - padding: 1 0; - } - - Input, Select SelectCurrent, Checkbox, TextArea { - border: tall black 20%; - &:focus { - border: tall $primary; - } - } - - Select:focus > SelectCurrent { - border: tall $primary; - } - - Input.-invalid { - border: tall $error 60%; - } -} diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index 68550f14b..069e87bcc 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -548,7 +548,7 @@ async def on_mount(self) -> None: @handles(PreferenceChanged) async def _preferences_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.launcher.agents: + if SETTINGS.launcher.agents.matches(message.event.field): await self.launcher.recompose() def focus_screen(): diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index 6dbc7a351..e42e6e923 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily -from agent_comms.comms import wire +from agent_comms.active_route import resolve_comms_route from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.unread import UnobservedUnread, UnreadPresentation @@ -50,7 +50,7 @@ async def reconnect(self, sessions: SessionNavigation, selected, snapshot, targe def source(self, sessions: SessionNavigation) -> MainScreen | None: return None - def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, session_id: str) -> bool: + async def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, session_id: str) -> bool: return False def depends_on(self, mode: str) -> bool: @@ -71,7 +71,7 @@ async def return_to(self, sessions: SessionNavigation) -> None: def forget(self, sessions: SessionNavigation) -> None: """Release any domain membership beyond the workspace factory itself.""" - def sync_identity(self, sessions: SessionNavigation, owner: str, previous: str, current: str) -> None: + async def sync_identity(self, sessions: SessionNavigation, owner: str, previous: str, current: str) -> None: pass def sync_recovery(self, sessions: SessionNavigation, owner: str, root: str | None) -> None: @@ -134,7 +134,7 @@ async def reconnect(self, sessions, selected, snapshot, targets): else: source.presentation.sources.attach_started(source, admission) - def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, session_id: str) -> bool: + async def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, session_id: str) -> bool: source = self.source(sessions) if source is None or not source.has_agent(): return False @@ -144,7 +144,15 @@ def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, sessi if session_id in {source._agent_session_id, live.session_id if live else None}: return True root = source.coordination_root - return root is not None and wire(root).registry.canonical_name(session_id) == source.resolve_thread() + if root is None: + return False + canonical = await sessions.app.preparation.run_thread( + lambda: sessions.app.coordination_access.acquire( + resolve_comms_route(root), + ).registry.canonical_name(session_id), + ) + return (self.source(sessions) is source and source.coordination_root == root + and canonical == source.resolve_thread()) def tab(self, sessions: SessionNavigation, observed: ObservedCommsService | None) -> OpenTab: source = self.source(sessions) @@ -219,11 +227,18 @@ async def return_to(self, sessions: SessionNavigation) -> None: await app.select_session(owner if owner in app.workspace_sessions.factories else app.tab_order.previous(self.mode)) - def sync_identity(self, sessions: SessionNavigation, owner: str, previous: str, current: str) -> None: + async def sync_identity(self, sessions: SessionNavigation, owner: str, previous: str, current: str) -> None: if self.key.owner_mode != owner: return - if self.key.me != previous and wire(self.key.root).registry.canonical_name(self.key.me) != current: - return + if self.key.me != previous: + key = self.key + canonical = await sessions.app.preparation.run_thread( + lambda: sessions.app.coordination_access.acquire( + resolve_comms_route(key.root), + ).registry.canonical_name(key.me), + ) + if self.key != key or canonical != current: + return self.key = replace(self.key, me=current) if view := sessions.app.workspace_sessions.views.get(self.mode): view.rebind_identity(current) diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index c5f08c742..edfcc5c99 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -170,11 +170,10 @@ async def launch(self, agent_identity: str, *, agent_session_id: str | None = No return project = project_path if project_path is not None else app.project_dir if agent_session_id is not None: - existing = next((entry for entry in self.members - if entry.accepts_launch(self, agent_identity, agent_session_id)), None) - if existing is not None: - await app.select_session(existing.mode) - return + for entry in self.members: + if await entry.accepts_launch(self, agent_identity, agent_session_id): + await app.select_session(entry.mode) + return await self.new(lambda: MainScreen(project, agent, agent_session_id, session_pk=session_pk, initial_prompt=initial_prompt), title=title or "New Session") @@ -246,9 +245,9 @@ def tabs(self) -> tuple[OpenTab, ...]: return self.app.tab_order.project({entry.mode: entry.tab(self, observed) for entry in self.members}) - def sync_identity(self, owner: str, previous: str, current: str) -> None: + async def sync_identity(self, owner: str, previous: str, current: str) -> None: for entry in self.members: - entry.sync_identity(self, owner, previous, current) + await entry.sync_identity(self, owner, previous, current) def sync_recovery(self, owner: str, root: str | None) -> None: for entry in self.members: diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index 3e9f3f17d..722996493 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -10,6 +10,7 @@ from agent_comms.cli_commands import TargetAction, TargetEdit, TargetBatchResult, TargetFailed from agent_comms.route_selection import RouteSelection +from agent_comms.tool_caller import NoThreadCaller if TYPE_CHECKING: from toad.app import ToadApp @@ -66,7 +67,7 @@ def __init__(self, owner, action, selected, subject): def apply(self): access = self.owner.app.coordination_access comms = access.require(self.selected) - return access.write(self.selected, self.action.request.apply, comms) + return access.write(self.selected, self.action.request.apply, comms, caller=NoThreadCaller()) async def run(self): app = self.owner.app @@ -134,9 +135,18 @@ def prepare(): except (OSError, ValueError) as error: ctx.app.notify(str(error), title=definition.label, severity='error') - if definition.editable_fields or definition.confirmation: - ctx.app.push_screen(CommandDialog(definition, ctx.title), accepted) - else: - async def prepare(): + async def prepare(): + if definition.editable_fields or definition.confirmation: + screen = ctx.app.screen + try: + fields = await ctx.app.preparation.run_thread( + lambda: asyncio.run(definition.prepare_fields(ctx.current().comms))) + ctx.current() + except (OSError, ValueError, RuntimeError) as error: + ctx.app.notify(str(error), title=definition.label, severity='error') + return + if ctx.app.screen is screen: + ctx.app.push_screen(CommandDialog(definition, ctx.title, fields, ctx.comms), accepted) + else: await accepted({}) - ctx.app.run_worker(prepare) + ctx.app.run_worker(prepare, name='command-fields') diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 6a1e79bd9..959b1d764 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -1,13 +1,121 @@ """Native parameter editing for one backend-declared operation.""" from __future__ import annotations -from agent_comms.cli_commands import TargetAction +import asyncio +from agent_comms.cli_commands import CatalogTargetField, TargetAction, TargetField +from agent_comms.login import run_login +from agent_comms.comms import Comms + +from textual import on, work from textual.app import ComposeResult +from textual.message import Message +from textual.reactive import var from textual.containers import Horizontal, Vertical, VerticalScroll from toad.screens.modal import ModalScreen from textual.widgets import Button, Checkbox, Input, Select, Static, TextArea +from toad.widgets.model_switcher import ModelSelection + + +class CatalogField(Button): + """One draft selection; the shared searchable picker presents its catalog.""" + value = var("") + + class Changed(Message): + def __init__(self, editor: CatalogField): + super().__init__() + self.editor = editor + + def __init__(self, parameter: CatalogTargetField): + self.parameter = parameter + super().__init__(name=parameter.name, + id='command-field-' + parameter.name.replace('_', '-')) + self.configure(parameter) + + def configure(self, parameter: CatalogTargetField): + self.parameter = parameter + self.value = (parameter.editor_default + if parameter.editor_default in {choice.value for choice in parameter.options} + else '') + self.update_label() + + def update_label(self): + self.label = next((choice.name for choice in self.parameter.options + if choice.value == self.value), 'Choose / connect a provider…') + + def watch_value(self): + self.update_label() + self.post_message(self.Changed(self)) + + def on_button_pressed(self, event: Button.Pressed): + event.stop() + self.choose() + + @work(exclusive=True) + async def choose(self): + value = await self.app.push_screen_wait(CatalogChoiceDialog(self)) + if value is not None and self.is_attached: + self.value = value + + +class CatalogSelection(ModelSelection): + choice_name = "choices" + + def __init__(self, editor: CatalogField): + self.editor = editor + super().__init__() + + @property + def choices(self): + return self.editor.parameter.options + + @property + def current(self): + return self.editor.value + + @property + def selection_active(self): + return self.is_attached + + def compose(self): + yield from self.compose_content() + + def on_mount(self): + self.focus_content(False) + + def select_model(self, model_id: str): + self.screen.dismiss(model_id) + + def action_dismiss(self): + self.screen.dismiss(None) + + @work(exclusive=True) + async def connect_provider(self): + command = self.editor.query_ancestor(CommandDialog) + try: + with self.app.suspend(): + result = await asyncio.to_thread(run_login, comms=command.comms) + except (OSError, ValueError, RuntimeError) as error: + self.notify(str(error), title='Provider login', severity='error') + return + if result == 0 and self.is_attached: + await command.refresh_catalogs(command.arguments()) + self.configuration_changed() + elif self.is_attached: + self.notify(f'Provider login exited with status {result}', severity='error') + if self.is_attached: + self.search_input.focus() + + +class CatalogChoiceDialog(ModalScreen[str]): + DEFAULT_CSS = 'CatalogChoiceDialog { align: center middle; background: $background 40%; }' + + def __init__(self, editor: CatalogField): + super().__init__() + self.editor = editor + def compose(self): + yield CatalogSelection(self.editor) class CommandDialog(ModalScreen[dict[str, str]]): DEFAULT_CSS = """ @@ -23,9 +131,11 @@ class CommandDialog(ModalScreen[dict[str, str]]): """ BINDINGS = [('escape', 'cancel', 'Cancel'), ('ctrl+enter', 'submit', 'Apply')] - def __init__(self, definition: TargetAction, target: str): + def __init__(self, definition: TargetAction, target: str, + fields: tuple[TargetField, ...], comms: Comms): super().__init__() self.definition, self.target = definition, target + self.fields, self.comms = fields, comms def compose(self) -> ComposeResult: with Vertical(id='command-box'): @@ -33,12 +143,17 @@ def compose(self) -> ComposeResult: with VerticalScroll(id='command-body'): yield Static('', markup=False, id='command-confirmation') with Vertical(id='command-fields'): - for parameter in self.definition.editable_fields: + for parameter in self.fields: key = parameter.name yield Static(parameter.description, markup=False) - if parameter.choices: - yield Select(parameter.choices, value=parameter.editor_default, - allow_blank=False, name=key, + if parameter.catalog is not None: + yield CatalogField(parameter) + elif (choices := parameter.choices) is not None: + value = parameter.editor_default + yield Select(choices, + value=value if value in {item[1] for item in choices} else Select.BLANK, + allow_blank=True, name=key, + disabled=not choices, id='command-field-' + key.replace('_', '-')) else: widget = TextArea if parameter.multiline else Input @@ -51,7 +166,7 @@ def compose(self) -> ComposeResult: def on_mount(self): self.update_confirmation() - fields = list(self.query('Input, TextArea, Select')) + fields = list(self.query('Input, TextArea, Select, CatalogField')) (fields[0] if fields else self.query_one('#command-cancel', Button)).focus() async def on_input_submitted(self, event: Input.Submitted): @@ -88,30 +203,74 @@ async def action_submit(self): def arguments(self): return {**{editor.name: editor.value for editor in self.query(Input)}, **{editor.name: editor.text for editor in self.query(TextArea)}, - **{editor.name: str(editor.value) for editor in self.query(Select)}} + **{editor.name: '' if editor.value is Select.BLANK else str(editor.value) + for editor in self.query(Select)}, + **{editor.name: editor.value for editor in self.query(CatalogField)}} + + async def refresh_catalogs(self, arguments): + requested = {item.name: arguments[item.name] for item in self.fields if item.catalog is not None} + def unchanged(): + current = self.arguments() + return all(current[name] == value for name, value in requested.items()) + self.query_one('#command-apply', Button).disabled = True + try: + fields = await self.app.preparation.run_thread( + lambda: asyncio.run(self.definition.edited(arguments).prepare_fields(self.comms))) + except (OSError, ValueError, RuntimeError) as error: + if self.is_attached and unchanged(): + self.notify(str(error), title='Configuration choices', severity='error') + return + if not self.is_attached or not unchanged(): + return + self.fields = fields + with self.prevent(CatalogField.Changed): + for parameter in fields: + if parameter.catalog is not None: + editor = self.query_one('#command-field-' + parameter.name.replace('_', '-'), CatalogField) + editor.configure(parameter) + self.update_confirmation() def update_confirmation(self): confirmed = self.query_one('#command-confirmed', Checkbox) confirmed.value = False self.query_one('#command-apply', Button).disabled = True arguments = self.arguments() + missing_choice = next((editor.name for editor in self.query(Select) + if editor.value is Select.BLANK), None) + if missing_choice is None: + missing_choice = next((editor.name for editor in self.query(CatalogField) + if not editor.value), None) async def prepare(): def read(): try: - return self.definition.edited(arguments).confirmation - except (KeyError, ValueError, TypeError): - return self.definition.confirmation - confirmation = await self.app.preparation.run_thread(read) + if missing_choice is not None: + raise ValueError(f'Choose {missing_choice.replace("_", " ")}') + return self.definition.edited(arguments).confirmation, True + except (KeyError, ValueError, TypeError) as error: + return str(error), False + confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): return self.query_one('#command-confirmation', Static).update(confirmation) - confirmed.display = bool(confirmation) - self.query_one('#command-apply', Button).disabled = False + confirmed.display = valid and bool(confirmation) + self.query_one('#command-apply', Button).disabled = not valid or any( + item.catalog is not None and item.editor_default != arguments[item.name] + for item in self.fields) self.run_worker(prepare, group='command-confirmation', exclusive=True) def on_select_changed(self, event: Select.Changed): self.update_confirmation() + @on(CatalogField.Changed) + def on_catalog_changed(self, event: CatalogField.Changed): + event.stop() + self.update_confirmation() + if event.editor.value and any( + item.name == event.editor.name and item.catalog is not None + and item.editor_default != event.editor.value for item in self.fields): + self.run_worker(self.refresh_catalogs(self.arguments()), + group='command-catalogs', exclusive=True) + def on_input_changed(self, event: Input.Changed): self.update_confirmation() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c6acfae9f..93dd8b86d 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -181,19 +181,13 @@ def on_mount(self) -> None: class CategorizedMount: - """Admit blocks in their declared order with the conversation's category selection.""" + """Mount blocks in their declared order with the conversation's category selection.""" def mount(self, *widgets, **kwargs): - from toad.widgets.message_filter import apply_block_filter, block_category, keep_live_block + from toad.widgets.message_filter import apply_block_filter selected = self.selected_categories for widget in require_blocks(widgets): - classes = {"-unrouted": not keep_live_block(widget)} - if category := block_category(widget): - classes[f"-message-{category.declared_name}"] = True - # Registration owns the first CSS match for detached blocks. For - # attached blocks, Textual applies this class change as one batch. - widget.update_classes(classes) apply_block_filter(widget, selected) mounted = super().mount(*widgets, **kwargs) # Registration is synchronous: establish document order before any @@ -1210,6 +1204,8 @@ async def action_provider_login(self) -> None: await self.prune_window(0, 0) self.output.boundary() await agent.session.reconnect() + if not agent.session.ready: + return else: await agent.session.authenticate(method_id) self.flash( @@ -1807,7 +1803,7 @@ async def replace_queued(self, prompts: list[str]) -> None: @handles(PreferenceChanged) async def _settings_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.shell.allow_commands: + if SETTINGS.shell.allow_commands.matches(message.event.field): self.input_histories.shell.complete.add_words( self.app.settings.shell.allow_commands.split() ) diff --git a/src/toad/widgets/message_filter.py b/src/toad/widgets/message_filter.py index 5bcb6ca0a..007c1ac05 100644 --- a/src/toad/widgets/message_filter.py +++ b/src/toad/widgets/message_filter.py @@ -1,6 +1,6 @@ """One semantic filter for native live blocks and saved transcript fragments.""" -from abc import ABC, abstractmethod +from abc import abstractmethod from agent_comms.declared_family import DeclaredFamily from agent_comms.mro_dispatch import MroDispatch, handles @@ -16,13 +16,7 @@ ) -class NativeMessagePresentation(ABC): - @classmethod - def keep_native_block(cls) -> bool: - return False - - -class MessageCategory(NativeMessagePresentation, DeclaredFamily, affix="Category"): +class MessageCategory(DeclaredFamily, affix="Category"): label: str @classmethod @@ -52,14 +46,6 @@ def observe(cls, boundary) -> bool: return show -class RoutedMessage(NativeMessagePresentation): - """Messages retained while the native transcript supplies ordinary blocks.""" - - @classmethod - def keep_native_block(cls) -> bool: - return True - - class UserCategory(FromPerson, MessageCategory): label = "User messages" @@ -68,11 +54,11 @@ class AgentCategory(FromAgent, MessageCategory): label = "Agent messages" -class InboundCategory(RoutedMessage, FromPerson, MessageCategory): +class InboundCategory(FromPerson, MessageCategory): label = "Messages in" -class OutboundCategory(RoutedMessage, FromAgent, MessageCategory): +class OutboundCategory(FromAgent, MessageCategory): label = "Messages out" @@ -161,22 +147,4 @@ def apply_block_filter(widget, selected: frozenset[type[MessageCategory]]) -> No """Change only this semantic owner, preserving its authored display rules.""" category = block_category(widget) hidden = category is not None and category not in selected - marker = "-category-hidden" - if widget.has_class(marker) != hidden: - # Keep the marker available for custom styling. Ordinary filtering is a - # model-owned display constraint, not a request to rematch subtree CSS. - update_styles = widget.is_attached and widget.app.stylesheet.references_class( - marker - ) - widget.set_class(hidden, marker, update=update_styles) widget.set_display_constraint("message-category", not hidden) - - -def keep_live_block(widget) -> bool: - category = block_category(widget) - return ( - category is None - or category.keep_native_block() - or widget.has_class("-error") - or widget.has_class("-error-log-link") - ) diff --git a/src/toad/widgets/model_switcher.py b/src/toad/widgets/model_switcher.py index 90123ff43..ebebc6fb7 100644 --- a/src/toad/widgets/model_switcher.py +++ b/src/toad/widgets/model_switcher.py @@ -4,12 +4,15 @@ from toad.core import input_events from toad.core_event_carrier import CoreEventReceiver -from typing import TYPE_CHECKING, Self +from abc import abstractmethod +from collections.abc import Sequence +from typing import TYPE_CHECKING, Self, Protocol from textual import events, getters, on, work from textual.app import ComposeResult from textual.binding import Binding from textual.content import Content +from textual.containers import VerticalGroup from textual.reactive import var from textual.widgets import Input, OptionList, Static from textual.widget import PrimaryActivation @@ -35,8 +38,7 @@ def primary_activation(self): return self.action_connect def action_connect(self): - self.query_ancestor(ModelSwitcher).action_dismiss() - self.publish_core(input_events.ProviderLogin()) + self.query_ancestor(ModelSelection).connect_provider() def on_click(self, event: events.Click): event.stop() @@ -62,7 +64,19 @@ def match_score(query: str, candidate: str) -> float: return 10 + len(query) / max(1, previous - first + 1) -class ModelSwitcher(CoreEventReceiver, InfoPopup): +class ModelChoice(Protocol): + @property + def value(self) -> str: ... + + @property + def name(self) -> str: ... + + @property + def description(self) -> str | None: ... + + +class ModelSelection(CoreEventReceiver, VerticalGroup): + """Search, recents and provider connection shared by prompt and creation views.""" BINDING_GROUP_TITLE = "Model search" BINDINGS = [ Binding("up", "cursor_up", "Previous model", priority=True), @@ -72,9 +86,7 @@ class ModelSwitcher(CoreEventReceiver, InfoPopup): Binding("ctrl+u", "clear_search", "Clear search", priority=True, show=False), ] DEFAULT_CSS = """ - ModelSwitcher { - display: none; - overlay: screen; + ModelSelection { constrain: inside inflect; width: 80; max-width: 100%; @@ -85,19 +97,18 @@ class ModelSwitcher(CoreEventReceiver, InfoPopup): padding: 0; margin: 1 0; } - ModelSwitcher.-open { display: block; } - ModelSwitcher Input { height: 1; border: none; padding: 0 1; } - ModelSwitcher .model-count { height: 1; padding: 0 1; color: $text-muted; } - ModelSwitcher ConnectProvider { height: 1; padding: 0 1; color: $text-secondary; pointer: pointer; } - ModelSwitcher ConnectProvider:hover, ModelSwitcher ConnectProvider:focus { text-style: underline; } - ModelSwitcher OptionList { + ModelSelection Input { height: 1; border: none; padding: 0 1; } + ModelSelection .model-count { height: 1; padding: 0 1; color: $text-muted; } + ModelSelection ConnectProvider { height: 1; padding: 0 1; color: $text-secondary; pointer: pointer; } + ModelSelection ConnectProvider:hover, ModelSelection ConnectProvider:focus { text-style: underline; } + ModelSelection OptionList { height: auto; max-height: 45vh; border: none; padding: 0; & > .option-list--option { padding: 0 1; } } - ModelSwitcher OptionList > .option-list--option-highlighted { + ModelSelection OptionList > .option-list--option-highlighted { background: $block-cursor-background; color: $block-cursor-foreground; text-style: $block-cursor-text-style; @@ -107,6 +118,7 @@ class ModelSwitcher(CoreEventReceiver, InfoPopup): search_input = getters.query_one(Input) option_list = getters.query_one(OptionList) history_scope = var("") + choice_name = "models" def __init__(self): super().__init__() @@ -114,13 +126,26 @@ def __init__(self): self._open_generation = 0 self._selection_moved = False - @classmethod - def for_prompt(cls, prompt: Prompt) -> Self | None: - from toad.widgets.prompt import Prompt - return None if prompt.simple_input else cls().data_bind(history_scope=Prompt.model_history_scope) + @property + @abstractmethod + def choices(self) -> Sequence[ModelChoice]: ... + + @property + @abstractmethod + def current(self) -> str: ... + + @property + @abstractmethod + def selection_active(self) -> bool: ... + + @abstractmethod + def select_model(self, model_id: str) -> None: ... + + @abstractmethod + def connect_provider(self) -> None: ... def compose_content(self) -> ComposeResult: - yield Input(placeholder="Search models or providers…", compact=True) + yield Input(placeholder=f"Search {self.choice_name} or providers…", compact=True) yield Static("", classes="model-count", markup=False) options = SelectionOptionList() options.can_focus = False @@ -128,7 +153,7 @@ def compose_content(self) -> ComposeResult: yield ConnectProvider() def configuration_changed(self) -> None: - if self.is_mounted and self.is_open: + if self.is_mounted and self.selection_active: self.filter_models(preserve_selection=True) def focus_content(self, scroll_visible: bool) -> None: @@ -144,7 +169,7 @@ def focus_content(self, scroll_visible: bool) -> None: async def load_recents(self, generation: int, scope: str) -> None: recent = await DB().recent_models(scope) if ( - self.is_open + self.selection_active and generation == self._open_generation and scope == self.history_scope ): @@ -159,11 +184,7 @@ def filter_models(self, *, preserve_selection: bool = False) -> None: previous_id = highlighted.id if highlighted is not None else None terms = self.search_input.value.casefold().split() recent = {model_id: index for index, model_id in enumerate(self.recent_ids)} - from toad.widgets.prompt import Prompt - agent = self.query_ancestor(Prompt).agent - setting = agent.configuration.model if agent is not None else None - models = setting.choices if setting is not None else () - current = setting.current if setting is not None else "" + models, current = self.choices, self.current ranked = [] for model in models: fields = {model.name.casefold(), model.value.casefold()} @@ -202,9 +223,9 @@ def filter_models(self, *, preserve_selection: bool = False) -> None: else: self.option_list.highlighted = None self.query_one(".model-count", Static).update( - f"{len(options)} / {len(models)} models · {'best matches' if terms else 'recent first'}" + f"{len(options)} / {len(models)} {self.choice_name} · {'best matches' if terms else 'recent first'}" if options - else "No matching models" + else f"No matching {self.choice_name}" ) @on(Input.Changed) @@ -228,10 +249,6 @@ def on_option_selected(self, event: OptionList.OptionSelected): if event.option_id is not None: self.select_model(event.option_id) - def select_model(self, model_id: str): - self.publish_core(input_events.ChangeModel(model_id)) - self.action_dismiss() - def action_cursor_up(self): self._selection_moved = True self.option_list.action_cursor_up() @@ -251,3 +268,36 @@ def action_submit(self): def action_clear_search(self): self.search_input.clear() + + +class ModelSwitcher(ModelSelection, InfoPopup): + DEFAULT_CSS = """ + ModelSwitcher { display: none; overlay: screen; } + ModelSwitcher.-open { display: block; } + """ + + @classmethod + def for_prompt(cls, prompt: Prompt) -> Self | None: + return None if prompt.simple_input else cls().data_bind(history_scope=type(prompt).model_history_scope) + + @property + def choices(self): + agent = self.prompt.agent + return agent.configuration.model.choices if agent is not None else () + + @property + def current(self): + agent = self.prompt.agent + return agent.configuration.model.current if agent is not None else "" + + @property + def selection_active(self): + return self.is_open + + def select_model(self, model_id: str): + self.publish_core(input_events.ChangeModel(model_id)) + self.action_dismiss() + + def connect_provider(self): + self.action_dismiss() + self.publish_core(input_events.ProviderLogin()) diff --git a/src/toad/widgets/recovery_view.py b/src/toad/widgets/recovery_view.py index 33d85a57b..2e34df6ec 100644 --- a/src/toad/widgets/recovery_view.py +++ b/src/toad/widgets/recovery_view.py @@ -58,7 +58,7 @@ def on_show(self) -> None: @handles(PreferenceChanged) async def _settings_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.ui.recovery_view: + if SETTINGS.ui.recovery_view.matches(message.event.field): self._apply_settings() def _apply_settings(self) -> None: diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index c0ef7cf62..6ede42d09 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -17,11 +17,13 @@ from textual.containers import VerticalScroll from toad.screens.modal import ModalScreen from textual.widgets import Static +from textual.css.scalar import Scalar, Unit +from textual.geometry import Spacing from textual.widget import PrimaryActivation -from toad.forms.declared_views import DeclaredField from toad.forms.field_path import FieldPath -from toad.forms.form_view import FormView +from toad.forms.form_view import InlineFormView +from toad.forms.presentation import FormModal, FormPart from toad.forms.kinds import Placeholder from toad.preferences import SETTINGS from toad.setting_choices import FamilyChoices @@ -43,7 +45,15 @@ class ChannelSortForm: ] -class SortDialog(ModalScreen[type[DisplayOrder] | None]): +class SortPanel(FormPart, VerticalScroll): + width = Scalar.from_number(52) + max_width = Scalar(90, Unit.PERCENT, Unit.WIDTH) + max_height = Scalar(90, Unit.VIEW_HEIGHT, Unit.VIEW_HEIGHT) + padding = Spacing(1, 2, 1, 2) + overflow_y = "auto" + + +class SortDialog(FormModal, ModalScreen[type[DisplayOrder] | None]): """A short-lived edit form, not a second source of persisted ordering.""" BINDINGS = [("escape", "cancel", "Close")] @@ -52,32 +62,24 @@ def __init__(self, control: SortControl, form: ThreadSortForm | ChannelSortForm) super().__init__() self.control = control self.state = ObjectState(form) - self.order_field = DeclaredField.at(self.state, FieldPath(type(form)).order) - self.form = FormView(self.state, focus=self.order_field.path) + self.order_field = FieldPath(type(form)).order.bind(self.state) + self.form = InlineFormView(self.state, focus=self.order_field.path) paths = control.visibility_fields() - self.visibility_form = (FormView(self.app.saved_settings.state, paths=paths) + self.visibility_form = (InlineFormView(self.app.saved_settings.state, paths=paths) if paths else None) def compose(self): - with VerticalScroll() as panel: - panel.styles.width = 52 - panel.styles.max_width = "90%" - panel.styles.height = "auto" - panel.styles.max_height = "90vh" - panel.styles.padding = (1, 2) + with SortPanel(): yield Static(f"Sort {self.control.scope}") - self.form.styles.height = "auto" yield self.form if self.visibility_form is not None: - self.visibility_form.styles.height = "auto" yield self.visibility_form def on_mount(self): - self.styles.align = ("center", "middle") self.state.on_resolved_changed(self.order_changed) def order_changed(self, paths: set[str]): - if str(self.order_field.path) in paths: + if self.order_field.path.value in paths: order = self.order_field.value if order is not None and order is not self.control.order: self.dismiss(order) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 1f67e26f2..1da5c2797 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -3,7 +3,6 @@ from toad.core import events as core_events from toad.core.preference_events import PreferenceChanged from toad.preferences import SETTINGS -from toad.forms.declared_views import DeclaredField from dataclasses import dataclass import asyncio from typing import TYPE_CHECKING, ClassVar, cast @@ -664,7 +663,7 @@ def on_mount(self) -> None: app = cast("ToadApp", self.app) if self._navigation is None: self.observe_core(app.saved_settings.events) - self.collapsed = DeclaredField.at(app.saved_settings.state, SETTINGS.sidebar.hide).value + self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value else: self.collapsed = self.hide self.watch_collapsed(self.collapsed) @@ -898,8 +897,8 @@ def render(self) -> str: @handles(PreferenceChanged) async def _settings_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.sidebar.hide: - self.collapsed = DeclaredField.at(self.app.saved_settings.state, message.event.path).value + if SETTINGS.sidebar.hide.matches(message.event.field): + self.collapsed = message.event.field.value @on(SideBarToggle.Pressed) def on_toggle_pressed(self, event: SideBarToggle.Pressed) -> None: @@ -934,7 +933,7 @@ def toggle(self, *, focus: bool = True) -> None: app.saved_settings.set(SETTINGS.sidebar.hide, collapsed) # Project this input on its visible owner immediately. The shared # settings signal still updates other views, idempotently. - self.collapsed = DeclaredField.at(app.saved_settings.state, SETTINGS.sidebar.hide).value + self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value else: self.collapsed = collapsed if collapsed: @@ -956,7 +955,7 @@ def reveal(self) -> None: self.collapsed = False def action_dismiss(self) -> None: - if DeclaredField.at(self.app.saved_settings.state, SETTINGS.sidebar.hide).value: + if SETTINGS.sidebar.hide.bind(self.app.saved_settings.state).value: self.collapsed = True self.post_message(self.Dismiss()) diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 77e110fc5..fe3d9bf30 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -278,7 +278,7 @@ def set_identity(self, owner: str, wire_root: str | None, *, if wire_root is not None: from toad.widgets.thread_comms_source import WireRelationshipSource - source = WireRelationshipSource(wire_root, self.app.coordination_access.observed_service) + source = WireRelationshipSource(wire_root, self.app.coordination_access) if (owner, wire_root) == (self.owner, self.wire_root) and source is self._source: return self.capture_state() diff --git a/src/toad/widgets/thread_comms_source.py b/src/toad/widgets/thread_comms_source.py index 02f9676ad..4a502f1ce 100644 --- a/src/toad/widgets/thread_comms_source.py +++ b/src/toad/widgets/thread_comms_source.py @@ -4,7 +4,8 @@ from pathlib import Path from typing import Protocol -from agent_comms.comms import Comms, wire +from agent_comms.active_route import resolve_comms_route +from toad.comms_root import CoordinationAccess from agent_comms.display_order import ThreadSort from agent_comms.relationships import SortableRelationshipGroup, ThreadCommsSnapshot @@ -21,16 +22,14 @@ def set_order(self, owner: str, group: type[SortableRelationshipGroup], class WireRelationshipSource: - def __init__(self, root: str, shared: Comms | None): + def __init__(self, root: str, access: CoordinationAccess): self.root = Path(root).expanduser().resolve() - self.shared = shared + self.access = access @cached_property def service(self): # Called by the widget's background read, never during composition. - comms = (self.shared if self.shared is not None and self.shared.root.resolve() == self.root - else wire(self.root)) - return comms.relationships + return self.access.acquire(resolve_comms_route(self.root)).relationships def revision(self): return self.service.revision() diff --git a/tests/textual/test_settings_screen.py b/tests/textual/test_settings_screen.py index af65a1c06..5c8967066 100644 --- a/tests/textual/test_settings_screen.py +++ b/tests/textual/test_settings_screen.py @@ -13,7 +13,7 @@ from ui_theme.scheme import PrimaryColour from toad.forms import editors -from toad.forms.field_path import FieldPath, leaves +from toad.forms.field_path import FieldPath from toad.forms.form_view import ValueView from toad.preferences import SETTINGS, SavedSettings, ToadSettings from toad.screens.settings import SchemeEditor, SettingsScreen @@ -100,8 +100,11 @@ async def test_opening_the_editors_changes_no_preference(workspace): async def test_every_setting_the_user_may_edit_has_an_editor(workspace): async with settings_screen(workspace) as (ui, app, _file): shown = app.screen.form.values - hidden = {"anon_id"} | {str(path) for path in leaves(SETTINGS.launcher)} - assert set(shown) == {str(path) for path in leaves(SETTINGS)} - hidden + structure = app.saved_settings.state.parameter_structure + hidden = {"anon_id"} | { + path.value for path in structure.leaf_paths(SETTINGS.launcher.bind(app.saved_settings.state).path.value) + } + assert set(shown) == {path.value for path in structure.leaf_paths()} - hidden assert isinstance(editor(app, SETTINGS.ui.footer), editors.BoolEditor) assert isinstance(editor(app, SETTINGS.ui.column_width), editors.IntEditor) assert isinstance(editor(app, SETTINGS.ui.theme), SchemeEditor) diff --git a/uv.lock b/uv.lock index 8c7e11042..10323ec12 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=688c0c5c23c71973abe823041f6c4d530b4c8ef4#688c0c5c23c71973abe823041f6c4d530b4c8ef4" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?rev=e56c4004aacef25f763a36f525227e1dd642db4a#e56c4004aacef25f763a36f525227e1dd642db4a" } dependencies = [ { name = "aiohttp" }, { name = "metaclass-registry" }, @@ -306,15 +306,15 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = "==0.12.1" }, - { name = "agent-comms", extras = ["acp"], git = "https://github.com/OpenHCSDev/agent-comms.git?rev=688c0c5c23c71973abe823041f6c4d530b4c8ef4" }, + { name = "agent-comms", extras = ["acp"], git = "https://github.com/OpenHCSDev/agent-comms.git?rev=e56c4004aacef25f763a36f525227e1dd642db4a" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, { name = "format-currency", specifier = ">=0.0.10" }, - { name = "formbridge", specifier = "==0.1.0" }, + { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?rev=dc5d21e482d086c02b07dbfa5916f409487894d4" }, { name = "httpx", specifier = "==0.28.1" }, { name = "notify-py", specifier = "==0.3.43" }, - { name = "objectstate", specifier = "==2.1.1" }, + { name = "objectstate", git = "https://github.com/OpenHCSDev/ObjectState.git?rev=82a6213263c9532f1b7cd4ebb2e9e368fdc5e2bd" }, { name = "packaging", specifier = "==26.2" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "platformdirs", specifier = "==4.9.6" }, @@ -322,7 +322,7 @@ requires-dist = [ { name = "pyperclip", specifier = "==1.11.0" }, { name = "rich", specifier = "==15.0.0" }, { name = "setproctitle", specifier = "==1.3.7" }, - { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=18f4e7a67403d56846ef5abf55c93e3e0639be33" }, + { name = "textual", extras = ["syntax"], git = "https://github.com/OpenHCSDev/textual.git?rev=e2fd8a336c49db0f16f22fc52618b378e30968cf" }, { name = "textual-diff-view", git = "https://github.com/OpenHCSDev/textual-diff-view.git?rev=8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }, { name = "textual-image", extras = ["textual"], git = "https://github.com/OpenHCSDev/textual-image.git?rev=1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" }, { name = "textual-serve", specifier = "==1.1.3" }, @@ -474,8 +474,8 @@ wheels = [ [[package]] name = "formbridge" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +version = "0.1.5" +source = { git = "https://github.com/OpenHCSDev/formbridge.git?rev=dc5d21e482d086c02b07dbfa5916f409487894d4#dc5d21e482d086c02b07dbfa5916f409487894d4" } dependencies = [ { name = "docutils" }, { name = "metaclass-registry" }, @@ -485,10 +485,6 @@ dependencies = [ { name = "pyzmq" }, { name = "zmqruntime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/42/ee83d07abb49ca79c6d889385f0dc8dcb87d8579647b7282c80dc1e2d17a/formbridge-0.1.0.tar.gz", hash = "sha256:89705201b12568427948ca30e85e5a0663c803de7f490691673184ddf17e1f58", size = 166196, upload-time = "2026-10-12T02:59:18.319Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/80/9c79e273ca0afae38256d1fc7847ab32f72b5574dfc04eced4b97ec5f19a/formbridge-0.1.0-py3-none-any.whl", hash = "sha256:22498e4433470cc4568ec19638f78260a700d67ef607e893941fe51f9c4634d3", size = 189865, upload-time = "2026-10-12T02:59:16.89Z" }, -] [[package]] name = "frozenlist" @@ -1093,17 +1089,13 @@ wheels = [ [[package]] name = "objectstate" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } +version = "4.0.1" +source = { git = "https://github.com/OpenHCSDev/ObjectState.git?rev=82a6213263c9532f1b7cd4ebb2e9e368fdc5e2bd#82a6213263c9532f1b7cd4ebb2e9e368fdc5e2bd" } dependencies = [ { name = "dill" }, { name = "pycodify" }, { name = "python-introspect" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/01/09c65f328378e73aa64c228e4980a9383ed89875219670e686627b52d31f/objectstate-2.1.1.tar.gz", hash = "sha256:ec68cbe3bbdbe1d9751cce777aabf2b0f154bce41084ca3efa5ae01fa4c92865", size = 207020, upload-time = "2026-10-12T02:42:42.179Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cc/70fb6afd3f7cc85924d0e42b067a095a8ef36a775e94033150601eb0cc75/objectstate-2.1.1-py3-none-any.whl", hash = "sha256:abe590848e00c062266b5ed9cb2ffae4ef868c7d1ad3d2b74abacf88e09ccd1f", size = 140857, upload-time = "2026-10-12T02:42:40.991Z" }, -] [[package]] name = "packaging" @@ -1489,15 +1481,15 @@ wheels = [ [[package]] name = "python-introspect" -version = "0.2.8" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "metaclass-registry" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/af/3e444a47e58de1cf4f1e5767b62ddbad55b69fd207960facde8566c9aa48/python_introspect-0.2.8.tar.gz", hash = "sha256:35ee71364dfaf897f4f577a8b526dc1e38afcbc2db0e33180b18bb7de09de51a", size = 58521, upload-time = "2026-10-11T19:34:25.627Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/71/55abd50b3f5da2560950c17de878021012f1c033618ad08187916a6f23fe/python_introspect-0.3.0.tar.gz", hash = "sha256:1b305b7976c5620b137edcb013a7a97356031d1abb0eec552a467e85633a7534", size = 62345, upload-time = "2026-10-12T21:00:11.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/4c/15f356c798be43f7b5f134ad67d56d4208b8fff2762938265d28b082307e/python_introspect-0.2.8-py3-none-any.whl", hash = "sha256:4e8ef0c50dd0dad8f300f68836cef4ecba4ffcc74a4b9d2d2624eebe03b167e4", size = 44387, upload-time = "2026-10-11T19:34:24.354Z" }, + { url = "https://files.pythonhosted.org/packages/9e/09/59afdbb10c00e3df9ed3f537a5d657a7b1df7189b7542dc9df5ba7d0810b/python_introspect-0.3.0-py3-none-any.whl", hash = "sha256:95155269ea563a5486e0c3851c868fdf0e15065097cd894efad949c69b69b777", size = 46792, upload-time = "2026-10-12T21:00:09.755Z" }, ] [[package]] @@ -1620,7 +1612,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?rev=18f4e7a67403d56846ef5abf55c93e3e0639be33#18f4e7a67403d56846ef5abf55c93e3e0639be33" } +source = { git = "https://github.com/OpenHCSDev/textual.git?rev=e2fd8a336c49db0f16f22fc52618b378e30968cf#e2fd8a336c49db0f16f22fc52618b378e30968cf" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" }, @@ -2175,7 +2167,7 @@ wheels = [ [[package]] name = "zmqruntime" -version = "0.6.4" +version = "0.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2187,7 +2179,7 @@ dependencies = [ { name = "pyzmq" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/64/df1de7577e7ae986625feb242e217477bc2dd29b9e905754324911f17eb1/zmqruntime-0.6.4.tar.gz", hash = "sha256:8e980bc4d0a2945c65d1dc94e9eb4d1a8599c9ee9cff680fe828ec9bda59314b", size = 727576, upload-time = "2026-10-12T12:35:33.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/b8/fed21fa56ddc7ea1e9d3c517fa54b8aea81cf0e5b8ca6086d477e0bb3de8/zmqruntime-0.6.5.tar.gz", hash = "sha256:b63cb6d3bd95d522e8db4213cd74a6ccb7058dc7d16bff756e08eb6585b2101b", size = 728551, upload-time = "2026-10-12T22:22:17.485Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/77/7451f76f64db3e9616461746be8841183962fb947df11cbc2e960071a371/zmqruntime-0.6.4-py3-none-any.whl", hash = "sha256:6869ecb54b1b4487007e0f07e8edcd9e48bf9616b544201793cd830c33e5dde2", size = 99055, upload-time = "2026-10-12T12:35:31.994Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/428582be6777f5c8be66bdd898597246e98d2cb674c412844d982e187121/zmqruntime-0.6.5-py3-none-any.whl", hash = "sha256:f27f91c4bca10f72985743ae53e05b1557c03380438d226f2714142cd127c76d", size = 99040, upload-time = "2026-10-12T22:22:16.116Z" }, ]