Files
cursor-flasher/src/cursor_flasher/detector.py
cottongin f4cbfb997e feat: add accessibility-based agent state detector
Adapted from plan to match real a11y tree structure: Electron web
content exposes in-app buttons as AXStaticText values, not AXButton
titles. Detects via exact matches and regex patterns. Uses correct
Cursor bundle ID for process lookup.

Made-with: Cursor
2026-03-10 02:37:59 -04:00

132 lines
4.2 KiB
Python

"""Accessibility-based detection of Cursor's agent state.
Detection strategy (based on a11y tree analysis):
- Cursor is an Electron app; web content is exposed via AXWebArea.
- In-app buttons render as AXStaticText with their label in the 'value' attr,
NOT as AXButton elements (those are native window controls only).
- We collect both AXStaticText values and AXButton titles, then match against
known keywords for "agent working" and "approval needed" states.
"""
from dataclasses import dataclass
import re
from ApplicationServices import (
AXUIElementCreateApplication,
AXUIElementCopyAttributeNames,
AXUIElementCopyAttributeValue,
)
from Cocoa import NSWorkspace
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
AGENT_WORKING_EXACT = {"Stop", "Cancel generating"}
AGENT_WORKING_PATTERNS = [re.compile(r"^Generating\b", re.IGNORECASE)]
APPROVAL_EXACT = {"Accept", "Reject", "Accept All", "Deny"}
APPROVAL_PATTERNS = [
re.compile(r"^Run\b", re.IGNORECASE),
re.compile(r"^Allow\b", re.IGNORECASE),
]
@dataclass
class UISignals:
agent_working: bool = False
approval_needed: bool = False
def _text_matches(text: str, exact_set: set[str], patterns: list[re.Pattern]) -> bool:
if text in exact_set:
return True
return any(p.search(text) for p in patterns)
def parse_ui_signals(elements: list[dict]) -> UISignals:
"""Parse flattened UI elements into detection signals."""
agent_working = False
approval_needed = False
for el in elements:
role = el.get("role", "")
label = ""
if role == "AXStaticText":
label = el.get("value", "")
elif role == "AXButton":
label = el.get("title", "")
if not label:
continue
if _text_matches(label, AGENT_WORKING_EXACT, AGENT_WORKING_PATTERNS):
agent_working = True
if _text_matches(label, APPROVAL_EXACT, APPROVAL_PATTERNS):
approval_needed = True
return UISignals(agent_working=agent_working, approval_needed=approval_needed)
class CursorDetector:
"""Polls Cursor's accessibility tree for agent state signals."""
def __init__(self):
self._pid: int | None = None
def poll(self) -> UISignals | None:
"""Poll Cursor's a11y tree and return detected signals, or None if Cursor isn't running."""
pid = self._find_cursor_pid()
if pid is None:
self._pid = None
return None
self._pid = pid
app_element = AXUIElementCreateApplication(pid)
elements = self._collect_elements(app_element, max_depth=15)
return parse_ui_signals(elements)
def _find_cursor_pid(self) -> int | None:
workspace = NSWorkspace.sharedWorkspace()
for app in workspace.runningApplications():
bundle = app.bundleIdentifier() or ""
if bundle == CURSOR_BUNDLE_ID:
return app.processIdentifier()
return None
def _collect_elements(
self, element, max_depth: int = 15, depth: int = 0
) -> list[dict]:
"""Walk the a11y tree collecting button and static text elements."""
if depth > max_depth:
return []
results: list[dict] = []
err, attr_names = AXUIElementCopyAttributeNames(element, None)
if err or not attr_names:
return results
role = ""
title = ""
value = ""
for name in attr_names:
val_err, val = AXUIElementCopyAttributeValue(element, name, None)
if val_err:
continue
if name == "AXRole":
role = str(val)
elif name == "AXTitle":
title = str(val) if val else ""
elif name == "AXValue":
value = str(val) if val else ""
if role == "AXStaticText" and value:
results.append({"role": role, "value": value})
elif role == "AXButton" and title:
results.append({"role": role, "title": title})
err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None)
if not err and children:
for child in children:
results.extend(self._collect_elements(child, max_depth, depth + 1))
return results