Merge pull request #90239 from NousResearch/bb/close-preview

The agent can close the preview pane, not just open it
This commit is contained in:
brooklyn!
2026-08-19 15:12:37 -05:00
committed by GitHub
14 changed files with 315 additions and 26 deletions
@@ -35,17 +35,27 @@ function Harness() {
return null
}
async function emitPreviewOpen(url = '/tmp/artifact-test.html') {
async function emitPreviewOpen(url = '/tmp/artifact-test.html', sessionId = RUNTIME_SESSION_ID) {
await act(async () => {
handleEvent({
payload: { label: 'hi bestie', url },
session_id: RUNTIME_SESSION_ID,
session_id: sessionId,
type: 'preview.open'
} as unknown as RpcEvent)
})
}
describe('open_preview', () => {
async function emitPreviewClose(url?: string, sessionId = RUNTIME_SESSION_ID) {
await act(async () => {
handleEvent({
payload: url === undefined ? {} : { url },
session_id: sessionId,
type: 'preview.close'
} as unknown as RpcEvent)
})
}
describe('preview routing', () => {
beforeEach(() => {
// A live session always has a runtime id; only the STORED id lags.
$activeSessionId.set(RUNTIME_SESSION_ID)
@@ -70,6 +80,8 @@ describe('open_preview', () => {
vi.restoreAllMocks()
})
describe('open_preview', () => {
// The rail used to hold a session-keyed singleton alongside its tabs, written
// under one session-id rule and reconciled under another. A live session with
// no stored id yet resolved to '' on the write side, so the target was set and
@@ -123,13 +135,7 @@ describe('open_preview', () => {
render(<Harness />)
try {
await act(async () => {
handleEvent({
payload: { url: '/tmp/from-tile.html' },
session_id: 'tile-runtime',
type: 'preview.open'
} as unknown as RpcEvent)
})
await emitPreviewOpen('/tmp/from-tile.html', 'tile-runtime')
await waitFor(() => expect($previewTarget.get()?.path).toBe('/tmp/from-tile.html'))
} finally {
@@ -199,4 +205,71 @@ describe('open_preview', () => {
expect($previewTabs.get()).toHaveLength(0)
})
})
describe('close_preview', () => {
it('closes the whole pane when no url is given', async () => {
render(<Harness />)
await emitPreviewOpen('/tmp/one.html')
await emitPreviewOpen('/tmp/two.html')
await waitFor(() => expect($previewTabs.get()).toHaveLength(2))
await emitPreviewClose('')
expect($previewTabs.get()).toHaveLength(0)
expect($previewTarget.get()).toBeNull()
})
it('closes only the matching tab when a url is given', async () => {
render(<Harness />)
await emitPreviewOpen('/tmp/keep.html')
await emitPreviewOpen('/tmp/drop.html')
await waitFor(() => expect($previewTabs.get()).toHaveLength(2))
await emitPreviewClose('/tmp/drop.html')
await waitFor(() => expect($previewTabs.get()).toHaveLength(1))
expect($previewTarget.get()?.path).toBe('/tmp/keep.html')
})
it('ignores a close from a session that is not the one on screen', async () => {
render(<Harness />)
await emitPreviewOpen('/tmp/stay.html')
await waitFor(() => expect($previewTabs.get()).toHaveLength(1))
await emitPreviewClose('/tmp/stay.html', 'some-other-session')
expect($previewTabs.get()).toHaveLength(1)
})
it('honors a close from an open tile session even when main holds focus', async () => {
const { $sessionTiles } = await import('@/store/session-states')
const tiles = $sessionTiles.get()
$sessionTiles.set([{ dir: 'right', runtimeId: 'tile-runtime', storedSessionId: 'stored-tile' }])
render(<Harness />)
try {
await emitPreviewOpen('/tmp/from-tile.html', 'tile-runtime')
await waitFor(() => expect($previewTabs.get()).toHaveLength(1))
await emitPreviewClose('/tmp/from-tile.html', 'tile-runtime')
await waitFor(() => expect($previewTabs.get()).toHaveLength(0))
} finally {
$sessionTiles.set(tiles)
}
})
it('is a no-op when nothing is open', async () => {
render(<Harness />)
await emitPreviewClose()
expect($previewTabs.get()).toHaveLength(0)
})
})
})
@@ -6,6 +6,8 @@ import { reachablePreviewUrl } from '@/lib/preview-reach'
import {
$previewTabs,
beginPreviewServerRestart,
closePreviewMatching,
closeRightRail,
completePreviewServerRestart,
openPreview,
progressPreviewServerRestart,
@@ -27,6 +29,14 @@ function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
function sessionIsOnScreen(sessionId: string): boolean {
return (
sessionId === $focusedRuntimeId.get() ||
sessionId === $activeSessionId.get() ||
$sessionTiles.get().some(tile => tile.runtimeId === sessionId)
)
}
export function usePreviewRouting({ baseHandleGatewayEvent, currentCwd, requestGateway }: PreviewRoutingOptions) {
const restartPreviewServer = useCallback(
async (url: string, context?: string) => {
@@ -76,12 +86,7 @@ export function usePreviewRouting({ baseHandleGatewayEvent, currentCwd, requestG
const { url, label } = asRecord(event.payload)
const target = typeof url === 'string' ? url.trim() : ''
const onScreen = (sid: string) =>
sid === $focusedRuntimeId.get() ||
sid === $activeSessionId.get() ||
$sessionTiles.get().some(tile => tile.runtimeId === sid)
if (target && (!event.session_id || onScreen(event.session_id))) {
if (target && (!event.session_id || sessionIsOnScreen(event.session_id))) {
void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(
async resolved => {
if (!resolved) {
@@ -103,6 +108,45 @@ export function usePreviewRouting({ baseHandleGatewayEvent, currentCwd, requestG
return
}
if (event.type === 'preview.close') {
// Agent-driven close via close_preview. Same on-screen gate as open:
// a session the user can see may tidy the pane it opened; a hidden
// background turn must not dismiss the user's preview.
const { url } = asRecord(event.payload)
const target = typeof url === 'string' ? url.trim() : ''
if (event.session_id && !sessionIsOnScreen(event.session_id)) {
return
}
if (!target) {
closeRightRail()
return
}
if (closePreviewMatching(target)) {
return
}
void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(
async resolved => {
const candidates = [target]
if (resolved) {
candidates.push(resolved.source, resolved.url)
if (resolved.kind === 'url') {
candidates.push(await reachablePreviewUrl(resolved.url))
}
}
closePreviewMatching(...candidates)
}
)
return
}
if (event.type === 'preview.restart.complete') {
const { task_id, text } = asRecord(event.payload)
+25
View File
@@ -8,6 +8,7 @@ import {
$previewTarget,
beginPreviewServerRestart,
closePreviewForSource,
closePreviewMatching,
closeRightRail,
closeRightRailTab,
openPreview,
@@ -127,6 +128,30 @@ describe('preview store', () => {
expect(closePreviewForSource('http://localhost:5174')).toBe(false)
})
it('closes a tab whose url or label matches even when source differs', () => {
openPreview(
{ kind: 'url', label: 'HN', source: 'https://news.ycombinator.com', url: 'https://news.ycombinator.com/' },
'tool-result'
)
expect(closePreviewMatching('https://news.ycombinator.com/')).toBe(true)
expect($previewTabs.get()).toHaveLength(0)
openPreview({ ...fileTarget('/work/demo.html'), label: 'Demo' }, 'tool-result')
expect(closePreviewMatching('Demo')).toBe(true)
expect($previewTabs.get()).toHaveLength(0)
})
it('does not wipe the rail on an empty or unknown close query', () => {
openPreview(fileTarget('/work/keep.html'), 'file-browser')
expect(closePreviewMatching()).toBe(false)
expect(closePreviewMatching(' ')).toBe(false)
expect(closePreviewMatching('https://missing.example')).toBe(false)
expect($previewTabs.get()).toHaveLength(1)
})
it('persists file and url tabs but never artifacts, whose content is memory-only', () => {
openPreview(fileTarget('/work/demo.html'), 'file-browser')
openPreview(urlTarget('http://localhost:5174'), 'tool-result')
+18 -1
View File
@@ -258,7 +258,24 @@ export function closeRightRailTab(tabId: string) {
/** Close the tab showing `source`, if one is open. Returns whether it closed. */
export function closePreviewForSource(source: string): boolean {
const tab = $previewTabs.get().find(item => item.target.source === source)
return closePreviewMatching(source)
}
/** Close the first tab whose source, url, or label matches any candidate.
* Empty candidates are a no-op so a missed match cannot wipe the rail —
* closing the whole pane is `closeRightRail`. */
export function closePreviewMatching(...candidates: string[]): boolean {
const queries = [...new Set(candidates.map(value => value.trim()).filter(Boolean))]
if (queries.length === 0) {
return false
}
const tab = $previewTabs.get().find(item => {
const fields = [item.target.source, item.target.url, item.target.label]
return queries.some(query => fields.includes(query))
})
if (!tab) {
return false
+61
View File
@@ -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"]
@@ -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",
+64
View File
@@ -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="🖼️",
)
+4 -2
View File
@@ -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",
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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`` /
+2 -1
View File
@@ -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__<server>__` (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). | — |
+1 -1
View File
@@ -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). |