diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx index 3af7455a953..e62cfb4ad07 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx @@ -120,6 +120,42 @@ describe('usePreviewRouting', () => { expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled() }) + it('opens the preview pane on a preview.open event for the active session', async () => { + render( + { + handleEvent = handler + }} + /> + ) + + act(() => + handleEvent({ payload: { url: 'https://www.cnn.com', label: 'CNN' }, session_id: 'session-1', type: 'preview.open' }) + ) + + await waitFor(() => { + expect($previewTarget.get()).toMatchObject({ kind: 'url', label: 'CNN', url: 'https://www.cnn.com' }) + }) + }) + + it('ignores a preview.open event for a background session', async () => { + render( + { + handleEvent = handler + }} + /> + ) + + act(() => + handleEvent({ payload: { url: 'https://www.cnn.com' }, session_id: 'other-session', type: 'preview.open' }) + ) + + // Give any (wrongly) scheduled async open a tick to resolve before asserting. + await Promise.resolve() + expect($previewTarget.get()).toBeNull() + }) + it('does not auto-open a preview from tool results', async () => { render( { baseHandleGatewayEvent(event) + if (event.type === 'preview.open') { + // Agent-driven open in response to an explicit user request ("show + // cnn.com in the preview pane"). Honor it only for the active session — + // a background turn must not yank the pane open (see desktop AGENTS.md: + // offer, don't hijack). Routes through the same normalizer as the file + // browser so URLs, localhost, and file paths all resolve correctly. + const { url, label } = asRecord(event.payload) + const target = typeof url === 'string' ? url.trim() : '' + + if (target && (!event.session_id || event.session_id === activeSessionIdRef.current)) { + void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(resolved => { + if (resolved) { + const trimmedLabel = typeof label === 'string' ? label.trim() : '' + setCurrentSessionPreviewTarget(trimmedLabel ? { ...resolved, label: trimmedLabel } : resolved, 'tool-result') + } + }) + } + + return + } + if (event.type === 'preview.restart.complete') { const { task_id, text } = asRecord(event.payload) @@ -126,7 +149,7 @@ export function usePreviewRouting({ requestPreviewReload() } }, - [activeSessionIdRef, baseHandleGatewayEvent] + [activeSessionIdRef, baseHandleGatewayEvent, currentCwd] ) return { handleDesktopGatewayEvent, restartPreviewServer } diff --git a/tests/tools/test_open_preview_tool.py b/tests/tools/test_open_preview_tool.py new file mode 100644 index 00000000000..db30f7e5e71 --- /dev/null +++ b/tests/tools/test_open_preview_tool.py @@ -0,0 +1,78 @@ +"""Tests for the desktop-gated ``open_preview`` tool.""" + +import json + +import pytest + +import tools.open_preview_tool as op + + +@pytest.fixture(autouse=True) +def _reset_emitter(): + """Each test controls the emitter; never leak one across tests.""" + op.set_preview_emitter(None) + yield + op.set_preview_emitter(None) + + +def test_gated_on_desktop(monkeypatch): + """Hidden unless HERMES_DESKTOP is set (mirrors read_terminal/close_terminal).""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + assert op.check_open_preview_requirements() is False + + monkeypatch.setenv("HERMES_DESKTOP", "1") + assert op.check_open_preview_requirements() is True + + +def test_requires_url(): + op.set_preview_emitter(lambda *a: None) + assert json.loads(op.open_preview_tool(" "))["error"] + + +def test_desktop_only_without_emitter(): + """No emitter wired (CLI/messaging) → clear desktop-only error, no raise.""" + result = json.loads(op.open_preview_tool("https://example.com")) + assert "desktop" in result["error"].lower() + + +def test_emits_with_ui_session_id(monkeypatch): + """The tool routes (sid, url, label) to the wired emitter, sid from context.""" + monkeypatch.setattr(op, "get_session_env", lambda name, default="": "win-42" if name == "HERMES_UI_SESSION_ID" else default) + calls = [] + op.set_preview_emitter(lambda sid, url, label: calls.append((sid, url, label))) + + out = json.loads(op.open_preview_tool("https://example.com/app", label="Docs")) + + assert out["success"] is True + assert out["url"] == "https://example.com/app" + assert calls == [("win-42", "https://example.com/app", "Docs")] + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("www.cnn.com", "https://www.cnn.com"), + ("example.com/path", "https://example.com/path"), + ("localhost:3000", "http://localhost:3000"), + ("127.0.0.1:8080/x", "http://127.0.0.1:8080/x"), + ("https://already.example", "https://already.example"), + ("/abs/path/index.html", "/abs/path/index.html"), + ("./rel/page.html", "./rel/page.html"), + ("`https://tick.example`", "https://tick.example"), + ], +) +def test_normalizes_bare_targets(raw, expected): + seen = {} + op.set_preview_emitter(lambda sid, url, label: seen.update(url=url)) + + op.open_preview_tool(raw) + + assert seen["url"] == expected + + +def test_emitter_failure_is_reported(monkeypatch): + def _boom(*_a): + raise RuntimeError("no window") + + op.set_preview_emitter(_boom) + assert "no window" in json.loads(op.open_preview_tool("https://x.example"))["error"] diff --git a/tools/open_preview_tool.py b/tools/open_preview_tool.py new file mode 100644 index 00000000000..371beaa7b77 --- /dev/null +++ b/tools/open_preview_tool.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Open a URL, dev server, or file in the Hermes desktop GUI's preview pane. + +The preview pane lives in the desktop renderer, so this tool bridges through a +gateway-injected emitter: the desktop ``tui_gateway`` wires ``set_preview_emitter`` +at session start to emit a ``preview.open`` event the renderer handles (opening +the pane beside the chat, scoped to the window that asked). Like ``read_terminal`` +and ``close_terminal`` it is gated on ``HERMES_DESKTOP`` so it never appears +outside the GUI. Fire-and-forget: the renderer never steals focus for a +background session. +""" + +import json +import re +from typing import Callable, Optional + +from gateway.session_context import get_session_env +from tools.registry import registry, tool_error +from utils import env_var_enabled + +# Set by the desktop gateway (tui_gateway) to bridge this tool → a renderer +# event. ``None`` everywhere else, which is how the tool reports "desktop only". +_preview_emitter: Optional[Callable[[str, str, str], None]] = None + + +def set_preview_emitter(fn: Optional[Callable[[str, str, str], None]]) -> None: + """Install the (sid, url, label) → emit sink. Called by the desktop gateway.""" + global _preview_emitter + _preview_emitter = fn + + +def _normalize_target(raw: str) -> str: + """Coax a bare host/domain into a fetchable URL; leave paths + schemes alone. + + ``www.cnn.com`` → ``https://www.cnn.com``; ``localhost:3000`` → + ``http://localhost:3000``. File paths and explicit schemes pass through for + the renderer's preview normalizer to classify. + """ + v = raw.strip().strip("`").strip() + if not v or "://" in v or v.startswith(("/", "./", "../", "~", "file:")): + return v + if re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:\d+)?(/|$)", v, re.I): + return "http://" + v + if re.match(r"^[\w.-]+\.[a-z]{2,}(:\d+)?(/.*)?$", v, re.I): + return "https://" + v + return v + + +def open_preview_tool(url: str, label: str = "") -> str: + """Ask the desktop GUI to show ``url`` in the preview pane beside the chat.""" + target = _normalize_target(url or "") + if not target: + return tool_error( + "url is required — a web URL (https://…), a localhost dev server, or a " + "file path to show in the preview pane." + ) + + emit = _preview_emitter + if emit is None: + return tool_error("The preview pane is only available in the Hermes desktop app.") + + label = (label or "").strip() + try: + emit(get_session_env("HERMES_UI_SESSION_ID", ""), target, label) + except Exception as exc: + return tool_error(f"Failed to open the preview pane: {exc}") + + return json.dumps({"success": True, "url": target, "label": label}, ensure_ascii=False) + + +def check_open_preview_requirements() -> bool: + """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" + return env_var_enabled("HERMES_DESKTOP") + + +OPEN_PREVIEW_SCHEMA = { + "name": "open_preview", + "description": ( + "Open something in the preview pane beside the chat in the Hermes desktop " + "app. Use this when the user asks to see a page, dev server, or file in the " + "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." + ), + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": ( + "What to preview: a web URL (https://… or a bare domain), a " + "localhost URL (localhost:3000), or a file path." + ), + }, + "label": { + "type": "string", + "description": "Optional tab label; defaults to the target's name.", + }, + }, + "required": ["url"], + }, +} + + +registry.register( + name="open_preview", + toolset="terminal", + schema=OPEN_PREVIEW_SCHEMA, + handler=lambda args, **kw: open_preview_tool(url=args.get("url", ""), label=args.get("label", "")), + check_fn=check_open_preview_requirements, + emoji="🖼️", +) diff --git a/toolsets.py b/toolsets.py index 1be62780d0c..e72a1696b8b 100644 --- a/toolsets.py +++ b/toolsets.py @@ -33,10 +33,10 @@ _HERMES_CORE_TOOLS = [ "web_search", "web_extract", # Terminal + process management "terminal", "process", - # Read the desktop GUI's embedded terminal pane, and close an agent's - # read-only terminal tab (both gated on HERMES_DESKTOP via check_fn — - # hidden outside the GUI). - "read_terminal", "close_terminal", + # Read the desktop GUI's embedded terminal pane, close an agent's read-only + # terminal tab, and open a URL/file in the preview pane (all gated on + # HERMES_DESKTOP via check_fn — hidden outside the GUI). + "read_terminal", "close_terminal", "open_preview", # File manipulation "read_file", "write_file", "patch", "search_files", # Vision + image generation diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03d8492d932..1886e31ccff 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10108,9 +10108,34 @@ def _wire_agent_terminal_output() -> None: process_registry.on_close = _emit_agent_terminal_close +_desktop_preview_wired = False + + +def _wire_desktop_preview() -> None: + """Bridge the desktop-only ``open_preview`` tool to a ``preview.open`` event. + + Idempotent. The tool reads ``HERMES_UI_SESSION_ID`` from the turn's context + and hands it back here as ``sid`` so the event routes to the window that + asked (``_emit``/``write_json`` is ``_stdout_lock``-guarded, so calling it + from the tool's thread is safe).""" + global _desktop_preview_wired + if _desktop_preview_wired: + return + try: + from tools import open_preview_tool + except Exception: + return + + open_preview_tool.set_preview_emitter( + lambda sid, url, label: _emit("preview.open", sid, {"url": url, "label": label}) + ) + _desktop_preview_wired = True + + def _start_notification_poller(sid: str, session: dict) -> threading.Event: """Start the background notification poller for a TUI session.""" _wire_agent_terminal_output() + _wire_desktop_preview() stop = threading.Event() t = threading.Thread( target=_notification_poller_loop,