Files
hermes-agent/tools/tip_tool.py
T
Brooklyn Nicholson 911c6c50d3 feat(tools): let Hermes point at one thing with the tip tool
The quiet sibling of `tour`, in the same `desktop_ui` toolset and reading the
same `tour(action='targets')` discovery call: one bubble with an arrow, for a
sentence that would be clearer with a finger on the thing it's about. Dimming
the whole app to say "the model name is a button" is the wrong weight.

Fire-and-forget rather than a round-trip, because a tip is not a question and
blocking the turn on one would stall the reply it belongs to. The renderer
enforces the user's opt-out itself, so a stale config read can never put a
bubble on a screen that asked for none.
2026-08-27 21:50:18 -05:00

136 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Point at something in the Hermes desktop GUI and say one line about it.
The quiet sibling of ``tour``. Same durable ``data-tour`` handles, same
discovery call (``tour(action="targets")``) — but no scrim, no spotlight, and no
Next/Prev. Just an accent-lit bubble with an arrow into whatever the tip is
about, which is the right weight for "that button, there" in the middle of a
sentence.
Fire-and-forget, unlike ``tour``: a tip is not a question, so blocking the turn
on a round-trip would stall the reply it belongs to. The renderer enforces the
user's Settings → Appearance → In-App Tips switch as well, so a stale config
read here can never put a bubble on a screen that asked for none.
Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for
desktop-sourced sessions.
"""
import json
from tools import desktop_ui
from tools.registry import registry, tool_error
SIDES = ("top", "right", "bottom", "left")
def tip_tool(text: str, selector: str, title: str = "", side: str = "") -> str:
"""Show one tip bubble anchored to ``selector``."""
text = (text or "").strip()
selector = (selector or "").strip()
if not text:
return tool_error("tip needs text — the one line the bubble says.")
if not selector:
return tool_error(
"tip needs a selector to point at. Call tour(action='targets') to see "
"what's on screen and prefer a target reporting stable: true."
)
if side and side not in SIDES:
return tool_error(f"side must be one of: {', '.join(SIDES)}.")
payload = {"selector": selector, "text": text}
if title:
payload["title"] = title
if side:
payload["side"] = side
try:
ok = desktop_ui.emit("tip.show", payload)
except Exception as exc:
return tool_error(f"Failed to show the tip: {exc}")
if not ok:
return tool_error("tip is only available in the Hermes desktop app.")
return json.dumps({"success": True, "selector": selector}, ensure_ascii=False)
def check_tips_enabled() -> bool:
"""The user's own switch (Settings → Appearance → In-App Tips).
Opt-OUT, so an absent key reads as ON — that matches the desktop's own
default, and the renderer only writes the key when the user changes it. The
desktop mirrors the toggle into ``display.in_app_tips`` on the CONNECTED
gateway's config, so this reads the right value whether that gateway is
local, SSH, URL, or cloud.
"""
try:
from hermes_cli.config import load_config_readonly
display = load_config_readonly().get("display")
except Exception:
return True
if not isinstance(display, dict):
return True
return bool(display.get("in_app_tips", True))
TIP_SCHEMA = {
"name": "tip",
"description": (
"Point at one thing in the Hermes desktop UI with a small accent-lit "
"bubble and an arrow — no dimming, no spotlight, no Next/Prev. Reach "
"for it when a sentence would be clearer with a finger on the thing "
"it's about: 'the model name is a button', 'your files are in here'. "
"Call tour(action='targets') first to see what's on screen and prefer "
"a target reporting `stable: true`; never guess a selector. One tip at "
"a time — a new one replaces the last. Say the same thing in chat as "
"well; the bubble is a pointer, not the message. Use it sparingly: the "
"app shows its own tips on a slow rotation, and a bubble on every turn "
"is what makes people turn the feature off."
),
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The one line the bubble says. Keep it to a sentence.",
},
"selector": {
"type": "string",
"description": (
"CSS selector of the element the arrow points at, from "
"tour(action='targets')."
),
},
"title": {
"type": "string",
"description": "Optional short heading above the text.",
},
"side": {
"type": "string",
"enum": list(SIDES),
"description": "Preferred side of the element. Omit for 'top'; it flips at a screen edge either way.",
},
},
"required": ["text", "selector"],
},
}
registry.register(
name="tip",
toolset="desktop_ui",
schema=TIP_SCHEMA,
handler=lambda args, **kw: tip_tool(
text=args.get("text", ""),
selector=args.get("selector", ""),
title=args.get("title", ""),
side=args.get("side", ""),
),
check_fn=check_tips_enabled,
emoji="💡",
)