mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): agent can focus panes + shared desktop-UI event bridge
Extract the open_preview emitter into a shared tools/desktop_ui bridge (one gateway-injected sink, routed by HERMES_UI_SESSION_ID) and add a second desktop-gated tool on top of it: - focus_pane(chat|files|terminal|review|sessions) -> pane.reveal event. The desktop runs each pane's own reveal path (revealDesktopPane table) and only acts on the active window -- a background turn never moves the user's focus (desktop AGENTS.md: offer, don't hijack). open_preview now emits through the same bridge. Both tools are check_fn on HERMES_DESKTOP (zero footprint elsewhere), sitting beside read_terminal/close_terminal in _HERMES_CORE_TOOLS. Deliberately not adding run_slash: letting the agent fire slash commands mid-turn (/model, /new, /clear) fights prompt-cache + conversation invariants.
This commit is contained in:
parent
f071f42244
commit
70ba3c4828
12 changed files with 301 additions and 57 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
apps/desktop/src/store/pane-focus.test.ts
Normal file
42
apps/desktop/src/store/pane-focus.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
30
apps/desktop/src/store/pane-focus.ts
Normal file
30
apps/desktop/src/store/pane-focus.ts
Normal file
|
|
@ -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<string, () => 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
|
||||
}
|
||||
30
tests/tools/test_desktop_ui.py
Normal file
30
tests/tools/test_desktop_ui.py
Normal file
|
|
@ -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"})]
|
||||
42
tests/tools/test_focus_pane_tool.py
Normal file
42
tests/tools/test_focus_pane_tool.py
Normal file
|
|
@ -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})]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
40
tools/desktop_ui.py
Normal file
40
tools/desktop_ui.py
Normal file
|
|
@ -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
|
||||
70
tools/focus_pane_tool.py
Normal file
70
tools/focus_pane_tool.py
Normal file
|
|
@ -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="🪟",
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue