feat(desktop): let the agent open the preview pane

Add a desktop-gated open_preview tool so 'open cnn.com in the preview
pane' works. The tool (check_fn on HERMES_DESKTOP, zero footprint
elsewhere) emits a preview.open event through a gateway-injected emitter,
mirroring the close_terminal -> terminal.close bridge. The desktop
handles it in usePreviewRouting, normalizing the target and opening the
pane for the active session only -- a background turn never hijacks it.

Bare domains and localhost are coaxed into fetchable URLs (www.cnn.com ->
https://, localhost:3000 -> http://); file paths and schemes pass through
to the renderer's normalizer.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 12:03:24 -05:00
parent 9b1028f297
commit f071f42244
6 changed files with 280 additions and 5 deletions

View file

@ -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(
<PreviewRoutingHarness
onEvent={handler => {
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(
<PreviewRoutingHarness
onEvent={handler => {
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(
<PreviewRoutingHarness

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import {
$previewTarget,
$sessionPreviewRegistry,
@ -10,6 +11,7 @@ import {
getSessionPreviewRecord,
progressPreviewServerRestart,
requestPreviewReload,
setCurrentSessionPreviewTarget,
setPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
@ -101,6 +103,27 @@ export function usePreviewRouting({
event => {
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 }

View file

@ -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"]

113
tools/open_preview_tool.py Normal file
View file

@ -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="🖼️",
)

View file

@ -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

View file

@ -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,