diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 7a019bb4230..5b8fccd8bb0 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -22,6 +22,7 @@ import { $gateway } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' +import { revealDesktopPane } from '@/store/pane-focus' import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { followActiveSessionCwd } from '@/store/projects' @@ -750,6 +751,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // Agent closed its own read-only tab via the desktop-gated close_terminal tool. // The process is untouched — this only drops the view. closeAgentTerminalByProc(payload?.process_id ?? '') + } else if (event.type === 'pane.reveal') { + // Agent revealed a pane via the desktop-gated focus_pane tool, in + // response to an explicit user request. Active session only — a + // background turn must never move the user's focus (desktop AGENTS.md: + // offer, don't hijack). + if (isActiveEvent) { + revealDesktopPane(payload?.pane ?? '') + } } else if (event.type === 'status.update') { if (sessionId && payload?.kind === 'compacting') { setSessionCompacting(sessionId, true) diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 0a6d56d5a6b..74beafcffd6 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -80,6 +80,8 @@ export type GatewayEventPayload = { count?: number // status.update (kind=process → background process completion/watch-match) kind?: string + // pane.reveal (agent focusing a desktop pane via the focus_pane tool) + pane?: string // session.title (live auto-title push) — stored session id + generated title session_id?: string title?: string diff --git a/apps/desktop/src/store/pane-focus.test.ts b/apps/desktop/src/store/pane-focus.test.ts new file mode 100644 index 00000000000..192a6d94188 --- /dev/null +++ b/apps/desktop/src/store/pane-focus.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { revealDesktopPane } from './pane-focus' + +const { openReview, revealTreePane, setFileBrowserOpen, setSidebarOpen, setTerminalTakeover } = vi.hoisted(() => ({ + openReview: vi.fn(), + revealTreePane: vi.fn(), + setFileBrowserOpen: vi.fn(), + setSidebarOpen: vi.fn(), + setTerminalTakeover: vi.fn() +})) + +vi.mock('@/app/right-sidebar/store', () => ({ setTerminalTakeover })) +vi.mock('@/components/pane-shell/tree/store', () => ({ revealTreePane })) +vi.mock('./layout', () => ({ setFileBrowserOpen, setSidebarOpen })) +vi.mock('./review', () => ({ openReview })) + +describe('revealDesktopPane', () => { + beforeEach(() => vi.clearAllMocks()) + + it("drives each pane's own reveal path", () => { + revealDesktopPane('chat') + expect(revealTreePane).toHaveBeenCalledWith('workspace') + revealDesktopPane('files') + expect(setFileBrowserOpen).toHaveBeenCalledWith(true) + revealDesktopPane('review') + expect(openReview).toHaveBeenCalledOnce() + revealDesktopPane('sessions') + expect(setSidebarOpen).toHaveBeenCalledWith(true) + revealDesktopPane('terminal') + expect(setTerminalTakeover).toHaveBeenCalledWith(true) + }) + + it('returns false for an unknown pane and touches nothing', () => { + expect(revealDesktopPane('nope')).toBe(false) + expect(revealTreePane).not.toHaveBeenCalled() + }) + + it('returns true for a known pane', () => { + expect(revealDesktopPane('terminal')).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/pane-focus.ts b/apps/desktop/src/store/pane-focus.ts new file mode 100644 index 00000000000..2b5c99e4ac6 --- /dev/null +++ b/apps/desktop/src/store/pane-focus.ts @@ -0,0 +1,30 @@ +import { setTerminalTakeover } from '@/app/right-sidebar/store' +import { revealTreePane } from '@/components/pane-shell/tree/store' + +import { setFileBrowserOpen, setSidebarOpen } from './layout' +import { openReview } from './review' + +// Explicit-request pane reveals, keyed to the backend `focus_pane` tool. Each +// entry drives the pane's own reveal path (some are toggle-bound) so a revealed +// pane matches a user-driven open. files/review are workspace-gated — a no-op +// without a project cwd, which is the honest behavior. +const PANE_REVEALERS: Record void> = { + chat: () => revealTreePane('workspace'), + files: () => setFileBrowserOpen(true), + review: () => openReview(), + sessions: () => setSidebarOpen(true), + terminal: () => setTerminalTakeover(true) +} + +/** Reveal a desktop pane by name. Returns false for an unknown pane. */ +export function revealDesktopPane(pane: string): boolean { + const reveal = PANE_REVEALERS[pane] + + if (!reveal) { + return false + } + + reveal() + + return true +} diff --git a/tests/tools/test_desktop_ui.py b/tests/tools/test_desktop_ui.py new file mode 100644 index 00000000000..4e35eab6c4b --- /dev/null +++ b/tests/tools/test_desktop_ui.py @@ -0,0 +1,30 @@ +"""Tests for the desktop-only renderer-event bridge.""" + +import pytest + +from tools import desktop_ui + + +@pytest.fixture(autouse=True) +def _reset_emitter(): + desktop_ui.set_emitter(None) + yield + desktop_ui.set_emitter(None) + + +def test_unavailable_without_emitter(): + assert desktop_ui.available() is False + assert desktop_ui.emit("preview.open", {"url": "x"}) is False + + +def test_routes_event_to_owning_window(monkeypatch): + monkeypatch.setattr( + desktop_ui, "get_session_env", + lambda name, default="": "win-7" if name == "HERMES_UI_SESSION_ID" else default, + ) + seen = [] + desktop_ui.set_emitter(lambda sid, event, payload: seen.append((sid, event, payload))) + + assert desktop_ui.available() is True + assert desktop_ui.emit("pane.reveal", {"pane": "terminal"}) is True + assert seen == [("win-7", "pane.reveal", {"pane": "terminal"})] diff --git a/tests/tools/test_focus_pane_tool.py b/tests/tools/test_focus_pane_tool.py new file mode 100644 index 00000000000..cef279d1bc3 --- /dev/null +++ b/tests/tools/test_focus_pane_tool.py @@ -0,0 +1,42 @@ +"""Tests for the desktop-gated ``focus_pane`` tool.""" + +import json + +import pytest + +from tools import desktop_ui, focus_pane_tool as fp + + +@pytest.fixture(autouse=True) +def _reset_emitter(): + desktop_ui.set_emitter(None) + yield + desktop_ui.set_emitter(None) + + +def test_gated_on_desktop(monkeypatch): + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + assert fp.check_focus_pane_requirements() is False + + monkeypatch.setenv("HERMES_DESKTOP", "1") + assert fp.check_focus_pane_requirements() is True + + +def test_rejects_unknown_pane(): + desktop_ui.set_emitter(lambda *a: None) + assert json.loads(fp.focus_pane_tool("banana"))["error"] + + +def test_desktop_only_without_emitter(): + assert "desktop" in json.loads(fp.focus_pane_tool("terminal"))["error"].lower() + + +@pytest.mark.parametrize("pane", fp.PANES) +def test_emits_pane_reveal(pane): + calls = [] + desktop_ui.set_emitter(lambda sid, event, payload: calls.append((event, payload))) + + out = json.loads(fp.focus_pane_tool(f" {pane.upper()} ")) + + assert out == {"success": True, "pane": pane} + assert calls == [("pane.reveal", {"pane": pane})] diff --git a/tests/tools/test_open_preview_tool.py b/tests/tools/test_open_preview_tool.py index db30f7e5e71..a401dde9d6d 100644 --- a/tests/tools/test_open_preview_tool.py +++ b/tests/tools/test_open_preview_tool.py @@ -4,15 +4,15 @@ import json import pytest -import tools.open_preview_tool as op +from tools import desktop_ui, 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) + desktop_ui.set_emitter(None) yield - op.set_preview_emitter(None) + desktop_ui.set_emitter(None) def test_gated_on_desktop(monkeypatch): @@ -25,7 +25,7 @@ def test_gated_on_desktop(monkeypatch): def test_requires_url(): - op.set_preview_emitter(lambda *a: None) + desktop_ui.set_emitter(lambda *a: None) assert json.loads(op.open_preview_tool(" "))["error"] @@ -35,17 +35,14 @@ def test_desktop_only_without_emitter(): 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) +def test_emits_preview_open(monkeypatch): calls = [] - op.set_preview_emitter(lambda sid, url, label: calls.append((sid, url, label))) + desktop_ui.set_emitter(lambda sid, event, payload: calls.append((event, payload))) 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")] + assert out == {"success": True, "url": "https://example.com/app", "label": "Docs"} + assert calls == [("preview.open", {"url": "https://example.com/app", "label": "Docs"})] @pytest.mark.parametrize( @@ -63,16 +60,16 @@ def test_emits_with_ui_session_id(monkeypatch): ) def test_normalizes_bare_targets(raw, expected): seen = {} - op.set_preview_emitter(lambda sid, url, label: seen.update(url=url)) + desktop_ui.set_emitter(lambda sid, event, payload: seen.update(payload)) op.open_preview_tool(raw) assert seen["url"] == expected -def test_emitter_failure_is_reported(monkeypatch): +def test_emitter_failure_is_reported(): def _boom(*_a): raise RuntimeError("no window") - op.set_preview_emitter(_boom) + desktop_ui.set_emitter(_boom) assert "no window" in json.loads(op.open_preview_tool("https://x.example"))["error"] diff --git a/tools/desktop_ui.py b/tools/desktop_ui.py new file mode 100644 index 00000000000..94955a29bda --- /dev/null +++ b/tools/desktop_ui.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Bridge desktop-only tools to Hermes-desktop renderer events. + +The preview pane, pane focus, and friends live in the desktop renderer, so +desktop-gated tools reach them through an emitter the desktop ``tui_gateway`` +installs at session start via :func:`set_emitter`. Everywhere else it stays +``None`` and the tools report "desktop only". Routing keys off +``HERMES_UI_SESSION_ID`` so the event lands on the window that owns the turn +(``_emit``/``write_json`` is ``_stdout_lock``-guarded, so emitting from the +tool's thread is safe). +""" + +from typing import Callable, Optional + +from gateway.session_context import get_session_env + +# (sid, event, payload) sink, installed by the desktop gateway. +_emit: Optional[Callable[[str, str, dict], None]] = None + + +def set_emitter(fn: Optional[Callable[[str, str, dict], None]]) -> None: + """Install (or clear) the renderer-event sink. Called by the desktop gateway.""" + global _emit + _emit = fn + + +def available() -> bool: + """True when running under the desktop app (an emitter is wired).""" + return _emit is not None + + +def emit(event: str, payload: dict) -> bool: + """Route ``event`` to the window that owns the current turn. + + Returns ``False`` when no emitter is wired (i.e. not the desktop app).""" + fn = _emit + if fn is None: + return False + fn(get_session_env("HERMES_UI_SESSION_ID", ""), event, payload) + return True diff --git a/tools/focus_pane_tool.py b/tools/focus_pane_tool.py new file mode 100644 index 00000000000..d854a1ab0f8 --- /dev/null +++ b/tools/focus_pane_tool.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Reveal/focus a pane in the Hermes desktop GUI. + +Gated on ``HERMES_DESKTOP`` (like the other GUI affordances). 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``. +""" + +import json + +from tools import desktop_ui +from tools.registry import registry, tool_error +from utils import env_var_enabled + +PANES = ("chat", "files", "terminal", "review", "sessions") + + +def focus_pane_tool(pane: str) -> str: + """Ask the desktop GUI to reveal and focus ``pane``.""" + name = (pane or "").strip().lower() + if name not in PANES: + return tool_error(f"pane must be one of: {', '.join(PANES)}.") + + try: + ok = desktop_ui.emit("pane.reveal", {"pane": name}) + except Exception as exc: + return tool_error(f"Failed to focus the {name} pane: {exc}") + if not ok: + return tool_error("Pane focus is only available in the Hermes desktop app.") + + return json.dumps({"success": True, "pane": name}, ensure_ascii=False) + + +def check_focus_pane_requirements() -> bool: + """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" + return env_var_enabled("HERMES_DESKTOP") + + +FOCUS_PANE_SCHEMA = { + "name": "focus_pane", + "description": ( + "Reveal and focus a pane in the Hermes desktop app when the user asks to " + "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." + ), + "parameters": { + "type": "object", + "properties": { + "pane": { + "type": "string", + "enum": list(PANES), + "description": "Which pane to reveal.", + }, + }, + "required": ["pane"], + }, +} + + +registry.register( + name="focus_pane", + toolset="terminal", + schema=FOCUS_PANE_SCHEMA, + handler=lambda args, **kw: focus_pane_tool(pane=args.get("pane", "")), + check_fn=check_focus_pane_requirements, + emoji="🪟", +) diff --git a/tools/open_preview_tool.py b/tools/open_preview_tool.py index 371beaa7b77..96b4d847e46 100644 --- a/tools/open_preview_tool.py +++ b/tools/open_preview_tool.py @@ -1,33 +1,19 @@ #!/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. +Gated on ``HERMES_DESKTOP`` (like ``read_terminal`` / ``close_terminal``) so it +never appears outside the GUI. Emits ``preview.open`` through the shared +``desktop_ui`` bridge; the renderer opens the pane beside the chat for the +window that asked and 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 import desktop_ui 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. @@ -55,15 +41,13 @@ def open_preview_tool(url: str, label: str = "") -> str: "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) + ok = desktop_ui.emit("preview.open", {"url": target, "label": label}) except Exception as exc: return tool_error(f"Failed to open 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, "label": label}, ensure_ascii=False) diff --git a/toolsets.py b/toolsets.py index e72a1696b8b..5cd53fa7240 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, 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", + # Desktop GUI affordances: read the embedded terminal pane, close an agent's + # read-only terminal tab, open a URL/file in the preview pane, and focus a + # pane (all gated on HERMES_DESKTOP via check_fn — hidden outside the GUI). + "read_terminal", "close_terminal", "open_preview", "focus_pane", # 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 1886e31ccff..054f1747f8f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10108,34 +10108,32 @@ def _wire_agent_terminal_output() -> None: process_registry.on_close = _emit_agent_terminal_close -_desktop_preview_wired = False +_desktop_ui_wired = False -def _wire_desktop_preview() -> None: - """Bridge the desktop-only ``open_preview`` tool to a ``preview.open`` event. +def _wire_desktop_ui() -> None: + """Bridge desktop-only tools (open_preview, focus_pane) to renderer events. - 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: + Idempotent. The tool hands back the turn's ``HERMES_UI_SESSION_ID`` 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_ui_wired + if _desktop_ui_wired: return try: - from tools import open_preview_tool + from tools import desktop_ui 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 + desktop_ui.set_emitter(lambda sid, event, payload: _emit(event, sid, payload)) + _desktop_ui_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() + _wire_desktop_ui() stop = threading.Event() t = threading.Thread( target=_notification_poller_loop,