mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
9b1028f297
commit
f071f42244
6 changed files with 280 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
78
tests/tools/test_open_preview_tool.py
Normal file
78
tests/tools/test_open_preview_tool.py
Normal 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
113
tools/open_preview_tool.py
Normal 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="🖼️",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue