diff --git a/src/serena/agent.py b/src/serena/agent.py index 7e9509d0..9b37a04b 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -12,6 +12,7 @@ import sys from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from datetime import datetime +from enum import Enum from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar @@ -246,6 +247,147 @@ class ActiveModes: return [SerenaAgentMode.load(mode_name) for mode_name in self._base_modes or []] +class DashboardManager: + class Mode(Enum): + BROWSER = "browser" + """ + Open the dashboard in the default browser; supported on all platforms. + """ + PYWEBVIEW = "webview" + """ + Open the dashboard via a native window (using pywebview) which minimises to the tray; supported on Windows only + (on macOS, tray apps for multiple instances accumulate in the top bar, which users do not want) + """ + TRAY_MANAGER = "tray_manager" + """ + Register dashboard instance with a central manager tray app (single tray icon for all instances), + spawning the tray manager if not already running; supported on macOS and Windows. + """ + + @classmethod + def from_platform(cls) -> "DashboardManager.Mode": + match platform.system(): + case "Windows": + return cls.PYWEBVIEW + case "Darwin": + return cls.TRAY_MANAGER + case _: + return cls.BROWSER + + def __init__(self, port: int, host_listen_address: str, open_dashboard_on_launch: bool, active_project: Project | None = None): + self._port = port + self._mode = self.Mode.from_platform() + self._dashboard_viewer_process: multiprocessing.Process | None = None + + dashboard_host = host_listen_address + if dashboard_host == "0.0.0.0": + dashboard_host = "localhost" + self.url = f"http://{dashboard_host}:{port}/dashboard/index.html" + + # handle startup + match self._mode: + case self.Mode.PYWEBVIEW: + self._start_dashboard_viewer(minimized=not open_dashboard_on_launch) + case self.Mode.TRAY_MANAGER: + self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project) + case self.Mode.BROWSER: + if open_dashboard_on_launch: + if not system_has_usable_display(): + log.info("Not opening the Serena dashboard because no usable display was detected.") + else: + self.open_dashboard_in_browser() + + @staticmethod + def _open_url_in_browser(url: str) -> None: + # Use a subprocess to avoid any output from webbrowser.open being written to stdout + subprocess.Popen( + [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=False, + ) + + def open_dashboard_in_browser(self) -> None: + return self._open_url_in_browser(self.url) + + @staticmethod + def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: + """ + Main function of the subprocess for starting the dashboard viewer + """ + try: + SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run() + except webview.errors.WebViewException as e: + log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") + # Fall back to opening the browser window if the window was supposed to be shown directly + if not minimized: + open_url_in_browser(url) + + def _start_dashboard_viewer(self, minimized: bool) -> None: + """ + Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, + opens the dashboard in the default web browser. + + :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). + If the viewer is not supported on the current platform, then this controls whether to open the browser window. + """ + self._dashboard_viewer_process = multiprocessing.Process( + target=self._start_dashboard_viewer_process_function, args=(self.url, minimized, os.getpid()), daemon=True + ) + self._dashboard_viewer_process.start() + + def _tray_manager_register(self, open_on_launch: bool, active_project: Project | None) -> None: + """ + Ensure the tray manager is running and register this dashboard instance with it. + + If the current platform supports the native tray manager, this method starts + the manager (if not already running) and registers the instance. Otherwise, + it falls back to opening the dashboard in the default web browser. + + :param open_on_launch: whether the dashboard should be opened immediately + """ + if not system_has_usable_display(): + log.info("Not starting the Serena tray manager because no usable display was detected.") + return + + # ensure the singleton tray manager process is running + SerenaDashboardTrayManager.ensure_running() + + # determine the current project name (if any) + project_name = active_project.project_name if active_project is not None else None + + # register this instance with the tray manager + SerenaDashboardTrayManager.register_instance( + port=self._port, + dashboard_url=self.url, + project=project_name, + started_at=datetime.now().isoformat(timespec="seconds"), + open_viewer=open_on_launch, + ) + + def shutdown(self) -> None: + """ + Frees resources, terminating the dashboard viewer process (if any) and unregistering from the tray manager (if applicable). + """ + if self._dashboard_viewer_process is not None: + log.info("Stopping the dashboard viewer process ...") + self._dashboard_viewer_process.terminate() + self._dashboard_viewer_process = None + if self._mode == self.Mode.TRAY_MANAGER: + SerenaDashboardTrayManager.unregister_instance(port=self._port) + + def update_active_project(self, active_project: Project | None) -> None: + """ + Updates the active project (where applicable). + + :param active_project: the currently active project or None if no project is active + """ + if self._mode == self.Mode.TRAY_MANAGER: + project_name = active_project.project_name if active_project is not None else None + SerenaDashboardTrayManager.update_project(port=self._port, project=project_name) + + class SerenaAgent: def __init__( self, @@ -269,8 +411,9 @@ class SerenaAgent: if necessary. """ self._active_project: Project | None = None + self._project_activation_callback = project_activation_callback self._gui_log_viewer: Optional["GuiLogViewer"] = None - self._dashboard_viewer_process: multiprocessing.Process | None = None + self._dashboard_manager: DashboardManager | None = None self.version = serena_version() @@ -285,9 +428,6 @@ class SerenaAgent: self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None ) - # dashboard URL (set when dashboard is started) - self._dashboard_url: str | None = None - # adjust log level serena_log_level = self.serena_config.log_level if Logger.root.level != serena_log_level: @@ -365,7 +505,6 @@ class SerenaAgent: # Initialize the prompt factory self.prompt_factory = SerenaPromptFactory() - self._project_activation_callback = project_activation_callback # activate the given project (if any), also updating the active modes # Note: We cannot update the active tools yet, because the base toolset has not been computed yet @@ -390,10 +529,6 @@ class SerenaAgent: self._active_tools: AvailableTools self._update_active_tools() - # record the agent start time (used for tray manager registration) - self._agent_started_at = datetime.now().isoformat(timespec="seconds") - self._dashboard_port: int | None = None - # start the dashboard (web frontend), registering its log handler # should be the last thing to happen in the initialization since the dashboard # may access various parts of the agent @@ -401,17 +536,13 @@ class SerenaAgent: self._dashboard_thread, port = SerenaDashboardAPI( get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats ).run_in_thread(host=self.serena_config.web_dashboard_listen_address) - self._dashboard_port = port - dashboard_host = self.serena_config.web_dashboard_listen_address - if dashboard_host == "0.0.0.0": - dashboard_host = "localhost" - dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" - self._dashboard_url = dashboard_url - log.info("Serena web dashboard started at %s", dashboard_url) - self._ensure_tray_manager_and_register(open_on_launch=self.serena_config.web_dashboard_open_on_launch) + self._dashboard_manager = DashboardManager( + port, self.serena_config.web_dashboard_listen_address, self.serena_config.web_dashboard_open_on_launch, self._active_project + ) + log.info("Serena web dashboard started at %s", self._dashboard_manager.url) # inform the GUI window (if any) if self._gui_log_viewer is not None: - self._gui_log_viewer.set_dashboard_url(dashboard_url) + self._gui_log_viewer.set_dashboard_url(self._dashboard_manager.url) self._send_usage_info() @@ -578,82 +709,9 @@ class SerenaAgent: """ :return: the URL of the web dashboard, or None if the dashboard is not running """ - return self._dashboard_url - - @staticmethod - def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: - """ - Main function of the subprocess for starting the dashboard viewer - """ - try: - SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run() - except webview.errors.WebViewException as e: - log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") - # Fall back to opening the browser window if the window was supposed to be shown directly - if not minimized: - SerenaAgent._open_dashboard_in_browser(url) - - def _start_dashboard_viewer(self, minimized: bool) -> None: - """ - Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, - opens the dashboard in the default web browser. - - :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). - If the viewer is not supported on the current platform, then this controls whether to open the browser window. - """ - if not system_has_usable_display(): - log.info("Not starting the Serena dashboard viewer because no usable display was detected.") - return - - url = self.get_dashboard_url() - assert url is not None - if SerenaDashboardViewer.is_current_platform_supported(): - self._dashboard_viewer_process = multiprocessing.Process( - target=self._start_dashboard_viewer_process_function, args=(url, minimized, os.getpid()), daemon=True - ) - self._dashboard_viewer_process.start() - else: - log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback") - if not minimized: - self._open_dashboard_in_browser(url) - - def _ensure_tray_manager_and_register(self, open_on_launch: bool) -> None: - """ - Ensure the tray manager is running and register this dashboard instance with it. - - If the current platform supports the native tray manager, this method starts - the manager (if not already running) and registers the instance. Otherwise, - it falls back to opening the dashboard in the default web browser. - - :param open_on_launch: whether the dashboard should be opened immediately - """ - if not system_has_usable_display(): - log.info("Not starting the Serena tray manager because no usable display was detected.") - return - - url = self.get_dashboard_url() - assert url is not None - assert self._dashboard_port is not None - - if SerenaDashboardTrayManager.is_current_platform_supported(): - # ensure the singleton tray manager process is running - SerenaDashboardTrayManager.ensure_running() - - # determine the current project name (if any) - project_name = self._active_project.project_name if self._active_project is not None else None - - # register this instance with the tray manager, optionally opening a viewer - SerenaDashboardTrayManager.register_instance( - port=self._dashboard_port, - dashboard_url=url, - project=project_name, - started_at=self._agent_started_at, - open_viewer=open_on_launch, - ) - else: - log.info("Not starting Serena tray manager because the current platform does not support it; using browser-based fallback") - if open_on_launch: - self._open_dashboard_in_browser(url) + if self._dashboard_manager is None: + return None + return self._dashboard_manager.url def open_dashboard(self) -> bool: """ @@ -661,27 +719,16 @@ class SerenaAgent: :return: True if the dashboard was opened, False if it could not be opened """ - if self._dashboard_url is None: + if self._dashboard_manager is None: raise Exception("Dashboard is not running.") if not system_has_usable_display(): log.warning("Not opening the Serena web dashboard because no usable display was detected.") return False - self._open_dashboard_in_browser(self._dashboard_url) + self._dashboard_manager.open_dashboard_in_browser() return True - @staticmethod - def _open_dashboard_in_browser(url: str) -> None: - # Use a subprocess to avoid any output from webbrowser.open being written to stdout - subprocess.Popen( - [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=False, - ) - def get_exposed_tool_instances(self) -> list["Tool"]: """ :return: the tool instances which are exposed (e.g. to the MCP client). @@ -900,10 +947,9 @@ class SerenaAgent: if self._project_activation_callback is not None: self._project_activation_callback() - # notify the tray manager of the project change (if the dashboard is running) - dashboard_port = getattr(self, "_dashboard_port", None) - if dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported(): - SerenaDashboardTrayManager.update_project(dashboard_port, project.project_name) + # notify the dashboard manager of the project change (if applicable) + if self._dashboard_manager: + self._dashboard_manager.update_active_project(self._active_project) return True @@ -1048,13 +1094,9 @@ class SerenaAgent: log.info("Stopping the GUI log window ...") self._gui_log_viewer.stop() self._gui_log_viewer = None - if self._dashboard_viewer_process: - log.info("Stopping the dashboard viewer process ...") - self._dashboard_viewer_process.terminate() - self._dashboard_viewer_process = None - if hasattr(self, "_dashboard_port"): - if self._dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported(): - SerenaDashboardTrayManager.unregister_instance(self._dashboard_port) + if self._dashboard_manager: + self._dashboard_manager.shutdown() + self._dashboard_manager = None def shutdown(self) -> None: """