From 60e9ed12c435c31497a31a5e90cc2dedc41f6e6e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 14:59:51 -0500 Subject: [PATCH] feat(tools): close_preview so the agent can dismiss the pane it opened open_preview and read_preview could drive the page, but nothing could close the pane. Same desktop_ui / session-source gate as the rest of the GUI tools. --- tests/tools/test_close_preview_tool.py | 61 ++++++++++++++++++ .../tui_gateway/test_gui_surface_toolsets.py | 1 + tools/close_preview_tool.py | 64 +++++++++++++++++++ tools/focus_pane_tool.py | 6 +- tools/open_preview_tool.py | 3 +- tools/read_preview_tool.py | 2 +- toolsets.py | 4 +- tui_gateway/server.py | 2 +- website/docs/reference/tools-reference.md | 3 +- website/docs/reference/toolsets-reference.md | 2 +- 10 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 tests/tools/test_close_preview_tool.py create mode 100644 tools/close_preview_tool.py diff --git a/tests/tools/test_close_preview_tool.py b/tests/tools/test_close_preview_tool.py new file mode 100644 index 0000000000..8cd11b5743 --- /dev/null +++ b/tests/tools/test_close_preview_tool.py @@ -0,0 +1,61 @@ +"""Tests for the GUI-surface ``close_preview`` tool.""" + +import json + +import pytest + +from tools import close_preview_tool as cp, desktop_ui +from tools.registry import registry + + +@pytest.fixture(autouse=True) +def _reset_emitter(): + """Each test controls the emitter; never leak one across tests.""" + desktop_ui.set_emitter(None) + yield + desktop_ui.set_emitter(None) + + +def test_lives_in_the_gui_surface_toolset(monkeypatch): + """Reaches a desktop client on ANY backend, including one with no + HERMES_DESKTOP in its environment (URL / cloud gateways).""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + entry = registry.get_entry("close_preview") + + assert entry is not None + assert entry.toolset == "desktop_ui" + assert entry.check_fn is None + + +def test_emits_preview_close_for_the_whole_pane(): + calls = [] + desktop_ui.set_emitter(lambda sid, event, payload: calls.append((event, payload))) + + out = json.loads(cp.close_preview_tool()) + + assert out == {"success": True, "url": ""} + assert calls == [("preview.close", {"url": ""})] + + +def test_normalizes_a_bare_domain_like_open_does(): + calls = [] + desktop_ui.set_emitter(lambda sid, event, payload: calls.append((event, payload))) + + out = json.loads(cp.close_preview_tool("www.cnn.com")) + + assert out == {"success": True, "url": "https://www.cnn.com"} + assert calls == [("preview.close", {"url": "https://www.cnn.com"})] + + +def test_reports_desktop_only_without_emitter(): + out = cp.close_preview_tool() + + assert "desktop app" in out + + +def test_emitter_failure_is_reported(): + def _boom(*_a): + raise RuntimeError("no window") + + desktop_ui.set_emitter(_boom) + assert "no window" in json.loads(cp.close_preview_tool("https://x.example"))["error"] diff --git a/tests/tui_gateway/test_gui_surface_toolsets.py b/tests/tui_gateway/test_gui_surface_toolsets.py index 8c236c68b3..ceebe004a3 100644 --- a/tests/tui_gateway/test_gui_surface_toolsets.py +++ b/tests/tui_gateway/test_gui_surface_toolsets.py @@ -18,6 +18,7 @@ import tui_gateway.server as server from toolsets import TOOLSETS, resolve_toolset GUI_TOOLS = { + "close_preview", "close_terminal", "focus_pane", "open_preview", diff --git a/tools/close_preview_tool.py b/tools/close_preview_tool.py new file mode 100644 index 0000000000..3fa15b298f --- /dev/null +++ b/tools/close_preview_tool.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Close the Hermes desktop GUI's preview pane, or one of its tabs. + +Lives in the ``desktop_ui`` toolset (same as ``open_preview``), which the GUI +gateway enables only for a session whose source is the desktop app. Emits +``preview.close`` through the shared ``desktop_ui`` bridge; the renderer drops +the matching tab — or the whole pane when no url is given — for the window +that asked and never steals a background session's view. +""" + +import json + +from tools import desktop_ui +from tools.open_preview_tool import _normalize_target +from tools.registry import registry, tool_error + + +def close_preview_tool(url: str = "") -> str: + """Ask the desktop GUI to close the preview pane, or the tab for ``url``.""" + target = _normalize_target(url or "") + + try: + ok = desktop_ui.emit("preview.close", {"url": target}) + except Exception as exc: + return tool_error(f"Failed to close the preview pane: {exc}") + if not ok: + return tool_error("The preview pane is only available in the Hermes desktop app.") + + return json.dumps({"success": True, "url": target}, ensure_ascii=False) + + +CLOSE_PREVIEW_SCHEMA = { + "name": "close_preview", + "description": ( + "Close the preview pane beside the chat in the Hermes desktop app, or one " + "tab inside it. Use this when the user asks to close, hide, or dismiss the " + "preview — e.g. \"close the preview pane\", \"close cnn.com\", \"hide the " + "preview\". Omit url to close the whole pane (every tab). Pass a web URL, " + "localhost address, or file path to close only that tab. Counterpart of " + "open_preview." + ), + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": ( + "Optional. The tab to close: a web URL (https://… or a bare " + "domain), a localhost URL, or a file path. Omit to close the " + "whole preview pane." + ), + }, + }, + }, +} + + +registry.register( + name="close_preview", + toolset="desktop_ui", + schema=CLOSE_PREVIEW_SCHEMA, + handler=lambda args, **kw: close_preview_tool(url=args.get("url") or ""), + emoji="🖼️", +) diff --git a/tools/focus_pane_tool.py b/tools/focus_pane_tool.py index 066341876a..e15431281b 100644 --- a/tools/focus_pane_tool.py +++ b/tools/focus_pane_tool.py @@ -5,7 +5,8 @@ Lives in the ``desktop_ui`` toolset (like the other GUI affordances), which the GUI gateway enables only for desktop-sourced sessions. Emits ``pane.reveal`` through the shared ``desktop_ui`` bridge; the renderer runs each pane's own reveal path and only acts on the active window (a background turn never moves -the user's focus). To show a URL/file, use ``open_preview``. +the user's focus). To show a URL/file, use ``open_preview``; to close it, use +``close_preview``. """ import json @@ -39,7 +40,8 @@ FOCUS_PANE_SCHEMA = { "see it — e.g. \"show me the terminal\", \"open the file browser\", \"show " "the diff\". Panes: chat (the conversation), files (project file browser), " "terminal (embedded shell), review (git diff), sessions (the session list). " - "To show a URL or file in the preview pane, use open_preview instead." + "To show a URL or file in the preview pane, use open_preview; to close it, " + "use close_preview." ), "parameters": { "type": "object", diff --git a/tools/open_preview_tool.py b/tools/open_preview_tool.py index f230b563d0..367a02764e 100644 --- a/tools/open_preview_tool.py +++ b/tools/open_preview_tool.py @@ -61,7 +61,8 @@ OPEN_PREVIEW_SCHEMA = { "preview pane — e.g. \"open cnn.com in the preview pane\" or \"preview " "localhost:3000\". Accepts a web URL (a bare domain like www.cnn.com is fine), " "a localhost dev-server URL, or a file path (HTML renders live; other files " - "show their contents). The pane opens for the current window only." + "show their contents). The pane opens for the current window only. To close " + "the pane or a tab, use close_preview." ), "parameters": { "type": "object", diff --git a/tools/read_preview_tool.py b/tools/read_preview_tool.py index 21376ab390..b4cf19693f 100644 --- a/tools/read_preview_tool.py +++ b/tools/read_preview_tool.py @@ -63,7 +63,7 @@ READ_PREVIEW_SCHEMA = { "per read); a file tab answers identity only (read the file with " "read_file); an artifact tab points back at the conversation. Use after " "open_preview, or whenever the user refers to what's on screen in the " - "browser ('what does this page say?')." + "browser ('what does this page say?'). To close the pane, use close_preview." ), "parameters": { "type": "object", diff --git a/toolsets.py b/toolsets.py index 18a6bf4227..7c5d032ad8 100644 --- a/toolsets.py +++ b/toolsets.py @@ -264,7 +264,7 @@ TOOLSETS = { }, # Affordances that only exist because a GUI renderer is on the other end of - # the connection: read/close the embedded terminal pane, open and read the + # the connection: read/close the embedded terminal pane, open/read/close the # in-app browser, focus a pane, tapback a message. # # Enabled by the GUI gateway for a session whose SOURCE is the desktop app @@ -276,7 +276,7 @@ TOOLSETS = { "description": "Desktop GUI affordances — in-app terminal/browser panes, pane focus, reactions (GUI sessions only)", "tools": [ "read_terminal", "close_terminal", - "open_preview", "read_preview", + "open_preview", "close_preview", "read_preview", "read_window_below", "focus_pane", "react_to_message", "setup_mcp", "tour", diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 11d2cb1791..621032da67 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10399,7 +10399,7 @@ _desktop_ui_wired = False def _wire_desktop_ui() -> None: - """Bridge desktop-only tools (open_preview, focus_pane) to renderer events. + """Bridge desktop-only tools (open_preview, close_preview, focus_pane) to renderer events. Idempotent. The tool hands back the turn's ``HERMES_UI_SESSION_ID`` as ``sid`` so the event routes to the window that asked (``_emit`` / diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 35b4ff466e..721ffb91ac 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -8,7 +8,7 @@ description: "Authoritative reference for Hermes built-in tools, grouped by tool This page documents Hermes' built-in tools, grouped by toolset. Availability varies by platform, credentials, and enabled toolsets. -**Quick counts (current registry):** ~83 tools — 10 browser tools (core) + 2 CDP-gated browser tools, 4 file tools, 4 Home Assistant tools, 2 terminal tools (`terminal`, `process`), 8 desktop-GUI tools (`read_terminal`, `close_terminal`, `open_preview`, `read_preview`, `read_window_below`, `focus_pane`, `react_to_message`, `tour` — desktop-app sessions only), 2 web tools, 5 Feishu tools, 7 Spotify tools (registered by the bundled `spotify` plugin), 5 Yuanbao tools, 12 kanban tools (registered when the kanban dispatcher spawns the agent), 3 project tools (desktop/GUI sessions), 2 Discord tools, 3 video tools (`video_generate`, `xai_video_edit`, `xai_video_extend`), and a handful of standalone tools (`memory`, `clarify`, `delegate_task`, `execute_code`, `cronjob`, `session_search`, `skill_view`/`skill_manage`/`skills_list`, `text_to_speech`, `image_generate`, `vision_analyze`, `video_analyze`, `todo`, `computer_use`, `x_search`). +**Quick counts (current registry):** ~84 tools — 10 browser tools (core) + 2 CDP-gated browser tools, 4 file tools, 4 Home Assistant tools, 2 terminal tools (`terminal`, `process`), 9 desktop-GUI tools (`read_terminal`, `close_terminal`, `open_preview`, `close_preview`, `read_preview`, `read_window_below`, `focus_pane`, `react_to_message`, `tour` — desktop-app sessions only), 2 web tools, 5 Feishu tools, 7 Spotify tools (registered by the bundled `spotify` plugin), 5 Yuanbao tools, 12 kanban tools (registered when the kanban dispatcher spawns the agent), 3 project tools (desktop/GUI sessions), 2 Discord tools, 3 video tools (`video_generate`, `xai_video_edit`, `xai_video_extend`), and a handful of standalone tools (`memory`, `clarify`, `delegate_task`, `execute_code`, `cronjob`, `session_search`, `skill_view`/`skill_manage`/`skills_list`, `text_to_speech`, `image_generate`, `vision_analyze`, `video_analyze`, `todo`, `computer_use`, `x_search`). :::tip MCP Tools In addition to built-in tools, Hermes can load tools dynamically from MCP servers. MCP tools appear with the prefix `mcp____` (e.g., `mcp__github__create_issue` for the `github` MCP server). See [MCP Integration](/user-guide/features/mcp) for configuration. @@ -197,6 +197,7 @@ messaging, and cron sessions. | `read_terminal` | Read what's currently shown in the in-app terminal pane of the Hermes desktop GUI (the embedded shell beside this chat). | — | | `close_terminal` | Close the read-only terminal tab for a background process in the Hermes desktop GUI. Does NOT kill the process — only drops the tab/view; use process(action='kill') to stop it. | — | | `open_preview` | Open a web URL, localhost dev-server URL, or file path in the preview pane beside the chat in the Hermes desktop app. | — | +| `close_preview` | Close the preview pane beside the chat, or one tab inside it. Omit `url` to close the whole pane; pass a URL or file path to close that tab. | — | | `read_preview` | Read what's currently shown in the preview pane of the Hermes desktop GUI — the in-app Browser's page text (URL + title + rendered text, pageable with `start`/`count`), or a file/artifact tab's identity. | — | | `read_window_below` | Identify the OS window directly underneath the Hermes desktop window — app name, title, bounds (metadata only, never pixels). On macOS, other apps' titles appear only when Screen Recording is already granted; the tool never prompts for it. | — | | `focus_pane` | Reveal and focus a pane in the Hermes desktop app (chat, files, terminal, review, sessions). | — | diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 5eba1ea86c..f71c2eeb1e 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -71,7 +71,7 @@ Or in-session: | `video_gen` | `video_generate`, `xai_video_edit`, `xai_video_extend` | Text-to-video and image-to-video via plugin-registered backends (xAI Grok-Imagine, FAL.ai Veo 3.1 / Pixverse v6 / Kling O3). Pass `image_url` to animate an image; omit it for text-to-video. `xai_video_edit` / `xai_video_extend` are provider-specific edit/extend tools, gated on xAI Imagine credentials. | | `kanban` | `kanban_attach`, `kanban_attach_url`, `kanban_attachments`, `kanban_block`, `kanban_comment`, `kanban_complete`, `kanban_create`, `kanban_heartbeat`, `kanban_link`, `kanban_list`, `kanban_request_changes`, `kanban_request_review`, `kanban_show`, `kanban_unblock` | Multi-agent coordination tools. Registered for dispatcher-spawned task workers (`HERMES_KANBAN_TASK`) and for profiles that explicitly list the `kanban` toolset by name (the `all`/`*` wildcard does **not** enable it). Workers mark tasks done, request first-class review, block, heartbeat, comment, and create/link follow-up tasks; orchestrator profiles additionally get board-routing tools like list/unblock. `delegate_task` children are not Kanban run owners: their schema strips/disables this toolset and runtime guards reject direct board mutations, even if parent `HERMES_KANBAN_*` env vars are present. | | `memory` | `memory` | Persistent cross-session memory management. | -| `desktop_ui` | `close_terminal`, `focus_pane`, `open_preview`, `react_to_message`, `read_preview`, `read_terminal`, `read_window_below`, `tour` | Affordances that act on the Hermes desktop app itself — read/close the embedded terminal pane, open and read the in-app browser, identify the OS window behind the app, reveal a pane, react to a message, run a guided tour (highlight + narrate UI elements in the app or the preview pane). Enabled for sessions whose source is the desktop app, whichever backend it's connected to (local, SSH, URL, or Hermes Cloud). Never present on CLI, TUI, messaging, or cron sessions. | +| `desktop_ui` | `close_preview`, `close_terminal`, `focus_pane`, `open_preview`, `react_to_message`, `read_preview`, `read_terminal`, `read_window_below`, `tour` | Affordances that act on the Hermes desktop app itself — read/close the embedded terminal pane, open/read/close the in-app browser, identify the OS window behind the app, reveal a pane, react to a message, run a guided tour (highlight + narrate UI elements in the app or the preview pane). Enabled for sessions whose source is the desktop app, whichever backend it's connected to (local, SSH, URL, or Hermes Cloud). Never present on CLI, TUI, messaging, or cron sessions. | | `project` | `project_create`, `project_list`, `project_switch` | Create and switch desktop [Projects](../user-guide/cli.md) (named, multi-folder workspaces). GUI / desktop sessions only. | | `safe` | `image_generate`, `vision_analyze`, `web_extract`, `web_search` (via `includes`) | Read-only research + media generation. No file writes, no terminal, no code execution. | | `search` | `web_search` | Web search only (without extract). |