feat: indexed UI tree with SoM annotations + improved element resolution

- Flat indexed UI element list with numeric IDs and center coordinates
- Screenshot annotations: numbered colored boxes (Set of Marks)
- click_element resolves by index from cached element list
- Cursor crosshair + focused element highlight in screenshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
David Piccinini 2026-05-17 03:03:08 -03:00
parent 3b4058afb9
commit f7acffd159

View File

@ -36,6 +36,40 @@ RECONNECT_DELAY_BASE = 5
RECONNECT_DELAY_MAX = 120 RECONNECT_DELAY_MAX = 120
TOOL_TIMEOUT = 60 # max seconds for a single tool call TOOL_TIMEOUT = 60 # max seconds for a single tool call
# ── UI Element Cache (Set of Marks) ─────────────────────────────────────────
_last_ui_elements: list[dict] = []
# Control types considered interactive for SoM indexing
_INTERACTIVE_TYPES = {
"ButtonControl", "EditControl", "CheckBoxControl", "RadioButtonControl",
"ListItemControl", "MenuItemControl", "ComboBoxControl", "TextControl",
"HyperlinkControl", "TabItemControl", "TreeItemControl", "DataItemControl",
"SliderControl", "MenuBarItemControl", "SpinnerControl", "SplitButtonControl",
"ToggleButtonControl",
}
# Color palette for SoM annotations (by control type category)
_SOM_COLORS: dict[str, str] = {
"ButtonControl": "#FF6B6B",
"EditControl": "#4ECDC4",
"CheckBoxControl": "#45B7D1",
"RadioButtonControl": "#45B7D1",
"ListItemControl": "#96CEB4",
"MenuItemControl": "#FFEAA7",
"ComboBoxControl": "#DDA0DD",
"TextControl": "#98D8C8",
"HyperlinkControl": "#87CEEB",
"TabItemControl": "#F7DC6F",
"TreeItemControl": "#82E0AA",
"DataItemControl": "#AED6F1",
"SliderControl": "#F1948A",
"MenuBarItemControl": "#FDEBD0",
"SpinnerControl": "#D2B4DE",
"SplitButtonControl": "#F5B041",
"ToggleButtonControl": "#FF6B6B",
}
# ── Tool Handlers ──────────────────────────────────────────────────────────── # ── Tool Handlers ────────────────────────────────────────────────────────────
@ -74,11 +108,12 @@ async def tool_run_command(args: dict[str, Any]) -> dict[str, Any]:
async def tool_get_ui_tree(args: dict[str, Any]) -> dict[str, Any]: async def tool_get_ui_tree(args: dict[str, Any]) -> dict[str, Any]:
"""Get the accessibility tree of a window or the desktop.""" """Get indexed interactive UI elements with coordinates (flat list for SoM)."""
global _last_ui_elements
import uiautomation as auto import uiautomation as auto
window_title = args.get("window") window_title = args.get("window")
max_depth = args.get("depth", 3) max_depth = args.get("depth", 8)
try: try:
if window_title: if window_title:
@ -93,46 +128,50 @@ async def tool_get_ui_tree(args: dict[str, Any]) -> dict[str, Any]:
if not root: if not root:
root = auto.GetRootControl() root = auto.GetRootControl()
def walk(ctrl: Any, depth: int, max_d: int) -> list[dict]: elements: list[dict] = []
if depth > max_d:
return [] def collect(ctrl: Any, depth: int) -> None:
items = [] if depth > max_depth:
return
try: try:
name = ctrl.Name or ""
ctrl_type = ctrl.ControlTypeName or "" ctrl_type = ctrl.ControlTypeName or ""
if ctrl_type in _INTERACTIVE_TYPES:
name = ctrl.Name or ""
auto_id = ctrl.AutomationId or "" auto_id = ctrl.AutomationId or ""
rect = ctrl.BoundingRectangle rect = ctrl.BoundingRectangle
value = "" if rect and rect.width() > 0 and rect.height() > 0:
try: cx = rect.left + rect.width() // 2
value = ctrl.GetValuePattern().Value if ctrl.GetValuePattern() else "" cy = rect.top + rect.height() // 2
except Exception: elem: dict[str, Any] = {
pass "id": len(elements),
item: dict[str, Any] = {
"type": ctrl_type, "type": ctrl_type,
"name": name, "name": name,
"coords": [cx, cy],
"rect": {"x": rect.left, "y": rect.top, "w": rect.width(), "h": rect.height()},
} }
if auto_id: if auto_id:
item["automation_id"] = auto_id elem["automation_id"] = auto_id
if value: # Try to get value
item["value"] = value try:
if rect and rect.width() > 0: vp = ctrl.GetValuePattern()
item["rect"] = {"x": rect.left, "y": rect.top, "w": rect.width(), "h": rect.height()} if vp:
val = vp.Value
children = [] if val:
if depth < max_d: elem["value"] = val
for child in ctrl.GetChildren(): except Exception:
children.extend(walk(child, depth + 1, max_d)) pass
if children: elements.append(elem)
item["children"] = children except Exception:
pass
items.append(item) try:
for child in ctrl.GetChildren():
collect(child, depth + 1)
except Exception: except Exception:
pass pass
return items
tree = walk(root, 0, max_depth) collect(root, 0)
return {"window": root.Name or "Desktop", "tree": tree} _last_ui_elements = elements
return {"window": root.Name or "Desktop", "element_count": len(elements), "elements": elements}
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@ -201,7 +240,8 @@ def _get_ui_feedback() -> dict[str, Any]:
async def tool_click_element(args: dict[str, Any]) -> dict[str, Any]: async def tool_click_element(args: dict[str, Any]) -> dict[str, Any]:
"""Click a UI element by name, automation_id, or coordinates.""" """Click a UI element by index (from UI tree), name, automation_id, or coordinates."""
index = args.get("index")
name = args.get("name") name = args.get("name")
auto_id = args.get("automation_id") auto_id = args.get("automation_id")
x = args.get("x") x = args.get("x")
@ -210,6 +250,16 @@ async def tool_click_element(args: dict[str, Any]) -> dict[str, Any]:
double = args.get("double", False) double = args.get("double", False)
try: try:
# Resolve index to coordinates from cached UI tree
if index is not None:
if not _last_ui_elements:
return {"error": "No UI tree cached. Call get_ui_tree first."}
if index < 0 or index >= len(_last_ui_elements):
return {"error": f"Index {index} out of range (0-{len(_last_ui_elements) - 1})"}
elem = _last_ui_elements[index]
x, y = elem["coords"]
logger.debug("Index %d resolved to (%d, %d) — %s", index, x, y, elem.get("name", ""))
if x is not None and y is not None: if x is not None and y is not None:
import pyautogui import pyautogui
if double: if double:
@ -295,11 +345,13 @@ async def tool_press_keys(args: dict[str, Any]) -> dict[str, Any]:
async def tool_screenshot(args: dict[str, Any]) -> dict[str, Any]: async def tool_screenshot(args: dict[str, Any]) -> dict[str, Any]:
"""Take a screenshot with UI annotations: cursor marker + focused element highlight.""" """Take a screenshot with Set of Marks (SoM) annotations from the UI tree."""
from PIL import ImageGrab, ImageDraw from PIL import ImageGrab, ImageDraw, ImageFont
try: try:
region = args.get("region") region = args.get("region")
annotate = args.get("annotate", True)
if region: if region:
bbox = (region["x"], region["y"], region["x"] + region["w"], region["y"] + region["h"]) bbox = (region["x"], region["y"], region["x"] + region["w"], region["y"] + region["h"])
img = ImageGrab.grab(bbox=bbox) img = ImageGrab.grab(bbox=bbox)
@ -343,6 +395,58 @@ async def tool_screenshot(args: dict[str, Any]) -> dict[str, Any]:
except Exception: except Exception:
pass pass
# 3. Set of Marks (SoM): draw indexed labels on interactive elements
if annotate and _last_ui_elements:
# Try to load a small font for labels
try:
font = ImageFont.truetype("arial.ttf", 11)
except Exception:
try:
font = ImageFont.truetype("C:\\Windows\\Fonts\\arial.ttf", 11)
except Exception:
font = ImageFont.load_default()
region_ox = region["x"] if region else 0
region_oy = region["y"] if region else 0
for elem in _last_ui_elements:
try:
r = elem["rect"]
ex = r["x"] - region_ox
ey = r["y"] - region_oy
ew = r["w"]
eh = r["h"]
# Skip elements outside visible area
if ex + ew < 0 or ey + eh < 0 or ex > img.width or ey > img.height:
continue
color = _SOM_COLORS.get(elem["type"], "#AAAAAA")
idx = elem["id"]
# Draw bounding box (thin outline)
draw.rectangle([ex, ey, ex + ew, ey + eh], outline=color, width=1)
# Draw index label (small colored background with white text)
label_text = str(idx)
# Get text size
text_bbox = draw.textbbox((0, 0), label_text, font=font)
tw = text_bbox[2] - text_bbox[0] + 4
th = text_bbox[3] - text_bbox[1] + 2
# Position label at top-left of element
lx = ex
ly = ey - th - 1
if ly < 0:
ly = ey # put inside if no room above
# Draw label background
draw.rectangle([lx, ly, lx + tw, ly + th], fill=color)
# Draw label text
draw.text((lx + 2, ly + 1), label_text, fill="white", font=font)
except Exception:
continue
# Resize for efficiency (max 1280px wide) # Resize for efficiency (max 1280px wide)
if img.width > 1280: if img.width > 1280:
ratio = 1280 / img.width ratio = 1280 / img.width