diff --git a/.gitignore b/.gitignore index 03e2b99..f251192 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ build/ .venv/ a11y_dump.txt agent-tools/ -.pytest_cache/ .cursor/ +docs/ chat-summaries/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..69f85b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cottongin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d4a0ebf..204db8e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cursor-flasher -Flash a colored border on the Cursor IDE window when the AI agent needs your attention — tool approval, questions, or task completion. +A macOS daemon that flashes a pulsing border and plays a sound when your [Cursor](https://cursor.com) AI agent needs attention. ## How It Works @@ -16,13 +16,14 @@ Only tools in the `approval_tools` list trigger the pulse (default: `Shell`, `Wr - macOS - [uv](https://docs.astral.sh/uv/) - Cursor IDE -- Accessibility permission for your terminal (System Settings → Privacy & Security → Accessibility) +- **Accessibility** permission for your terminal (System Settings → Privacy & Security → Accessibility) — needed for window enumeration +- **Input Monitoring** permission for the daemon process (System Settings → Privacy & Security → Input Monitoring) — needed for input-based pulse dismissal ## Installation ```bash # Clone and install -git clone && cd cursor-flasher +git clone https://code.cottongin.xyz/cursor-flasher && cd cursor-flasher uv sync # Install Cursor hooks (global, applies to all projects) diff --git a/docs/plans/2026-03-10-cursor-flasher-design.md b/docs/plans/2026-03-10-cursor-flasher-design.md deleted file mode 100644 index fefc516..0000000 --- a/docs/plans/2026-03-10-cursor-flasher-design.md +++ /dev/null @@ -1,102 +0,0 @@ -# Cursor Flasher — Design Document - -**Date:** 2026-03-10 -**Status:** Approved - -## Problem - -When Cursor's AI agent finishes its turn and is waiting for user input (approval, answering a question, or continuing the conversation), there's no visual or audible signal. The user has to keep checking the window manually. - -## Solution - -A macOS background daemon that monitors Cursor's accessibility tree and displays a pulsing border overlay around the Cursor window when the agent is waiting for user input. Optionally plays a system sound. - -## Architecture - -### Detection: Accessibility Tree Polling - -A Python process using `pyobjc` polls Cursor's accessibility tree every ~500ms via `AXUIElement` APIs. - -**Detection signals:** -- **Approval needed:** Accept/Reject button elements appear in the a11y tree -- **Agent turn complete:** Stop/Cancel button disappears, or thinking indicator goes away -- **Chat input active:** Chat text area becomes the focused element after being inactive - -**State machine:** -- `IDLE` — not monitoring (Cursor not in chat or not running) -- `AGENT_WORKING` — agent is generating (Stop button visible, thinking indicator present) -- `WAITING_FOR_USER` — agent is done, user hasn't interacted yet → **trigger flash** -- `USER_INTERACTING` — user started typing/clicking → dismiss flash, return to IDLE - -The detection heuristics will be tuned during development after dumping Cursor's full a11y tree. - -### Visual Effect: Native macOS Overlay - -- A borderless, transparent, non-interactive `NSWindow` positioned over Cursor's window frame -- Draws only a pulsing border (interior is fully click-through) -- Core Animation drives the pulse: opacity oscillates between configurable min/max -- Default: ~4px amber border, 1.5s cycle - -**Dismissal triggers:** -- Keyboard input to Cursor (via accessibility or `CGEventTap`) -- Mouse click in Cursor's chat area -- Timeout (default 5 minutes) -- Agent starts working again (Stop button reappears) - -### Sound - -On transition to `WAITING_FOR_USER`, optionally plays a macOS system sound (default: "Glass"). Configurable sound name, volume, and on/off toggle. - -### Configuration - -File: `~/.cursor-flasher/config.yaml` - -```yaml -pulse: - color: "#FF9500" - width: 4 - speed: 1.5 - opacity_min: 0.3 - opacity_max: 1.0 - -sound: - enabled: true - name: "Glass" - volume: 0.5 - -detection: - poll_interval: 0.5 - cooldown: 3.0 - -timeout: - auto_dismiss: 300 -``` - -### Process Management - -- CLI: `cursor-flasher start`, `cursor-flasher stop`, `cursor-flasher status` -- Runs as a background daemon -- No menu bar icon (MVP scope) - -## Tech Stack - -- Python 3.10+ -- `pyobjc-framework-Cocoa` — NSWindow, NSApplication, Core Animation -- `pyobjc-framework-Quartz` — AXUIElement, CGEventTap, window management -- `PyYAML` — configuration file parsing - -## Installation - -- `pip install -e .` from the project directory -- Requires Accessibility permission: System Settings > Privacy & Security > Accessibility (grant to Terminal or Python) - -## Scope / Non-goals - -- **In scope:** Detection, overlay, sound, CLI, config file -- **Not in scope (MVP):** Menu bar icon, auto-start on login, multi-monitor awareness, Linux/Windows support - -## Risks - -- **A11y tree fragility:** Cursor UI updates could change element names/structure, breaking detection. Mitigation: make detection patterns configurable, log warnings on detection failures. -- **Accessibility permissions:** Users must grant permission manually. Mitigation: clear error message and instructions on first run. -- **Performance:** Polling a11y tree every 500ms could have CPU cost. Mitigation: only poll when Cursor is the frontmost app or recently active. diff --git a/docs/plans/2026-03-10-cursor-flasher-implementation.md b/docs/plans/2026-03-10-cursor-flasher-implementation.md deleted file mode 100644 index dc09f84..0000000 --- a/docs/plans/2026-03-10-cursor-flasher-implementation.md +++ /dev/null @@ -1,1377 +0,0 @@ -# Cursor Flasher Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build a macOS daemon that monitors Cursor's accessibility tree and shows a pulsing border overlay when the agent is waiting for user input. - -**Architecture:** A Python background process polls Cursor's a11y tree via pyobjc, runs a state machine to detect "waiting for user" states, and manages a native macOS overlay window with Core Animation for the pulse effect. A CLI provides start/stop/status commands. - -**Tech Stack:** Python 3.10+, pyobjc-framework-Cocoa, pyobjc-framework-Quartz, PyYAML - ---- - -### Task 1: Project Scaffolding - -**Files:** -- Create: `pyproject.toml` -- Create: `requirements.txt` -- Create: `src/cursor_flasher/__init__.py` -- Create: `src/cursor_flasher/__main__.py` -- Create: `tests/__init__.py` -- Create: `tests/conftest.py` - -**Step 1: Create `pyproject.toml`** - -```toml -[build-system] -requires = ["setuptools>=68.0", "wheel"] -build-backend = "setuptools.backends._legacy:_Backend" - -[project] -name = "cursor-flasher" -version = "0.1.0" -description = "Flash Cursor's window when the AI agent is waiting for input" -requires-python = ">=3.10" -dependencies = [ - "pyobjc-framework-Cocoa", - "pyobjc-framework-Quartz", - "PyYAML", -] - -[project.optional-dependencies] -dev = ["pytest", "pytest-mock"] - -[project.scripts] -cursor-flasher = "cursor_flasher.cli:main" - -[tool.setuptools.packages.find] -where = ["src"] -``` - -**Step 2: Create `requirements.txt`** - -``` -pyobjc-framework-Cocoa -pyobjc-framework-Quartz -PyYAML -pytest -pytest-mock -``` - -**Step 3: Create package files** - -`src/cursor_flasher/__init__.py`: -```python -"""Cursor Flasher — flash the Cursor window when the AI agent is waiting for input.""" - -__version__ = "0.1.0" -``` - -`src/cursor_flasher/__main__.py`: -```python -from cursor_flasher.cli import main - -if __name__ == "__main__": - main() -``` - -`tests/__init__.py`: empty file - -`tests/conftest.py`: -```python -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -``` - -**Step 4: Install in dev mode** - -Run: `pip install -e ".[dev]"` -Expected: installs successfully, `cursor-flasher` command is available (will fail since cli module doesn't exist yet — that's fine) - -**Step 5: Commit** - -```bash -git add -A -git commit -m "feat: project scaffolding with pyproject.toml and package structure" -``` - ---- - -### Task 2: Configuration Module - -**Files:** -- Create: `src/cursor_flasher/config.py` -- Create: `tests/test_config.py` - -**Step 1: Write the failing tests** - -`tests/test_config.py`: -```python -import pytest -from pathlib import Path -from cursor_flasher.config import Config, load_config, DEFAULT_CONFIG - - -class TestDefaultConfig: - def test_has_pulse_settings(self): - cfg = Config() - assert cfg.pulse_color == "#FF9500" - assert cfg.pulse_width == 4 - assert cfg.pulse_speed == 1.5 - assert cfg.pulse_opacity_min == 0.3 - assert cfg.pulse_opacity_max == 1.0 - - def test_has_sound_settings(self): - cfg = Config() - assert cfg.sound_enabled is True - assert cfg.sound_name == "Glass" - assert cfg.sound_volume == 0.5 - - def test_has_detection_settings(self): - cfg = Config() - assert cfg.poll_interval == 0.5 - assert cfg.cooldown == 3.0 - - def test_has_timeout_settings(self): - cfg = Config() - assert cfg.auto_dismiss == 300 - - -class TestLoadConfig: - def test_loads_from_yaml(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text( - "pulse:\n" - ' color: "#00FF00"\n' - " width: 8\n" - "sound:\n" - " enabled: false\n" - ) - cfg = load_config(config_file) - assert cfg.pulse_color == "#00FF00" - assert cfg.pulse_width == 8 - assert cfg.sound_enabled is False - # Unset values use defaults - assert cfg.pulse_speed == 1.5 - assert cfg.sound_name == "Glass" - - def test_missing_file_returns_defaults(self, tmp_path): - cfg = load_config(tmp_path / "nonexistent.yaml") - assert cfg.pulse_color == "#FF9500" - - def test_empty_file_returns_defaults(self, tmp_path): - config_file = tmp_path / "config.yaml" - config_file.write_text("") - cfg = load_config(config_file) - assert cfg.pulse_color == "#FF9500" -``` - -**Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_config.py -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'cursor_flasher.config'` - -**Step 3: Implement config module** - -`src/cursor_flasher/config.py`: -```python -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import yaml - - -@dataclass -class Config: - pulse_color: str = "#FF9500" - pulse_width: int = 4 - pulse_speed: float = 1.5 - pulse_opacity_min: float = 0.3 - pulse_opacity_max: float = 1.0 - - sound_enabled: bool = True - sound_name: str = "Glass" - sound_volume: float = 0.5 - - poll_interval: float = 0.5 - cooldown: float = 3.0 - - auto_dismiss: int = 300 - - -FIELD_MAP: dict[str, dict[str, str]] = { - "pulse": { - "color": "pulse_color", - "width": "pulse_width", - "speed": "pulse_speed", - "opacity_min": "pulse_opacity_min", - "opacity_max": "pulse_opacity_max", - }, - "sound": { - "enabled": "sound_enabled", - "name": "sound_name", - "volume": "sound_volume", - }, - "detection": { - "poll_interval": "poll_interval", - "cooldown": "cooldown", - }, - "timeout": { - "auto_dismiss": "auto_dismiss", - }, -} - - -DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml" - - -def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: - """Load config from YAML, falling back to defaults for missing values.""" - if not path.exists(): - return Config() - - with open(path) as f: - raw = yaml.safe_load(f) - - if not raw or not isinstance(raw, dict): - return Config() - - overrides: dict[str, Any] = {} - for section, mapping in FIELD_MAP.items(): - section_data = raw.get(section, {}) - if not isinstance(section_data, dict): - continue - for yaml_key, field_name in mapping.items(): - if yaml_key in section_data: - overrides[field_name] = section_data[yaml_key] - - return Config(**overrides) -``` - -**Step 4: Run tests to verify they pass** - -Run: `pytest tests/test_config.py -v` -Expected: all PASS - -**Step 5: Commit** - -```bash -git add -A -git commit -m "feat: add configuration module with YAML loading and defaults" -``` - ---- - -### Task 3: State Machine - -**Files:** -- Create: `src/cursor_flasher/state.py` -- Create: `tests/test_state.py` - -**Step 1: Write the failing tests** - -`tests/test_state.py`: -```python -import pytest -from cursor_flasher.state import FlasherState, StateMachine - - -class TestStateMachine: - def test_initial_state_is_idle(self): - sm = StateMachine() - assert sm.state == FlasherState.IDLE - - def test_idle_to_agent_working(self): - sm = StateMachine() - changed = sm.update(agent_working=True, approval_needed=False) - assert sm.state == FlasherState.AGENT_WORKING - assert changed is True - - def test_agent_working_to_waiting(self): - sm = StateMachine() - sm.update(agent_working=True, approval_needed=False) - changed = sm.update(agent_working=False, approval_needed=False) - assert sm.state == FlasherState.WAITING_FOR_USER - assert changed is True - - def test_approval_needed_triggers_waiting(self): - sm = StateMachine() - sm.update(agent_working=True, approval_needed=False) - changed = sm.update(agent_working=False, approval_needed=True) - assert sm.state == FlasherState.WAITING_FOR_USER - assert changed is True - - def test_idle_does_not_jump_to_waiting(self): - sm = StateMachine() - changed = sm.update(agent_working=False, approval_needed=False) - assert sm.state == FlasherState.IDLE - assert changed is False - - def test_waiting_to_user_interacting(self): - sm = StateMachine() - sm.update(agent_working=True, approval_needed=False) - sm.update(agent_working=False, approval_needed=False) - assert sm.state == FlasherState.WAITING_FOR_USER - changed = sm.dismiss() - assert sm.state == FlasherState.IDLE - assert changed is True - - def test_waiting_to_agent_working(self): - sm = StateMachine() - sm.update(agent_working=True, approval_needed=False) - sm.update(agent_working=False, approval_needed=False) - changed = sm.update(agent_working=True, approval_needed=False) - assert sm.state == FlasherState.AGENT_WORKING - assert changed is True - - def test_no_change_returns_false(self): - sm = StateMachine() - sm.update(agent_working=True, approval_needed=False) - changed = sm.update(agent_working=True, approval_needed=False) - assert changed is False - - def test_cooldown_prevents_immediate_retrigger(self): - sm = StateMachine(cooldown=5.0) - sm.update(agent_working=True, approval_needed=False) - sm.update(agent_working=False, approval_needed=False) - assert sm.state == FlasherState.WAITING_FOR_USER - sm.dismiss() - # Immediately go back through AGENT_WORKING -> done - sm.update(agent_working=True, approval_needed=False) - changed = sm.update(agent_working=False, approval_needed=False) - # Should stay IDLE due to cooldown (unless we mock time) - # This test validates cooldown is stored; actual time-based test below - assert sm.cooldown == 5.0 - - def test_direct_approval_from_idle(self): - """If we detect approval buttons without seeing agent_working first, - still transition to WAITING_FOR_USER.""" - sm = StateMachine() - changed = sm.update(agent_working=False, approval_needed=True) - assert sm.state == FlasherState.WAITING_FOR_USER - assert changed is True -``` - -**Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_state.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement state machine** - -`src/cursor_flasher/state.py`: -```python -import enum -import time - - -class FlasherState(enum.Enum): - IDLE = "idle" - AGENT_WORKING = "agent_working" - WAITING_FOR_USER = "waiting_for_user" - - -class StateMachine: - def __init__(self, cooldown: float = 3.0): - self.state = FlasherState.IDLE - self.cooldown = cooldown - self._last_dismiss_time: float = 0 - - def update(self, *, agent_working: bool, approval_needed: bool) -> bool: - """Update state based on detected signals. Returns True if state changed.""" - old = self.state - - if approval_needed and self.state != FlasherState.WAITING_FOR_USER: - if not self._in_cooldown(): - self.state = FlasherState.WAITING_FOR_USER - return self.state != old - - match self.state: - case FlasherState.IDLE: - if agent_working: - self.state = FlasherState.AGENT_WORKING - case FlasherState.AGENT_WORKING: - if not agent_working: - if not self._in_cooldown(): - self.state = FlasherState.WAITING_FOR_USER - else: - self.state = FlasherState.IDLE - case FlasherState.WAITING_FOR_USER: - if agent_working: - self.state = FlasherState.AGENT_WORKING - - return self.state != old - - def dismiss(self) -> bool: - """User interaction detected — dismiss the flash.""" - if self.state == FlasherState.WAITING_FOR_USER: - self.state = FlasherState.IDLE - self._last_dismiss_time = time.monotonic() - return True - return False - - def _in_cooldown(self) -> bool: - if self._last_dismiss_time == 0: - return False - return (time.monotonic() - self._last_dismiss_time) < self.cooldown -``` - -**Step 4: Run tests to verify they pass** - -Run: `pytest tests/test_state.py -v` -Expected: all PASS - -**Step 5: Commit** - -```bash -git add -A -git commit -m "feat: add state machine for agent activity detection" -``` - ---- - -### Task 4: Accessibility Tree Explorer (Dev Tool) - -This is a development utility to understand Cursor's a11y tree before writing the detector. - -**Files:** -- Create: `scripts/dump_a11y_tree.py` - -**Step 1: Write the exploration script** - -`scripts/dump_a11y_tree.py`: -```python -"""Dump the accessibility tree of the Cursor application. - -Usage: python scripts/dump_a11y_tree.py [--depth N] - -Requires Accessibility permissions for the running terminal. -""" -import argparse -import sys - -from ApplicationServices import ( - AXUIElementCreateApplication, - AXUIElementCopyAttributeNames, - AXUIElementCopyAttributeValue, -) -from Cocoa import NSWorkspace - - -def find_cursor_pid() -> int | None: - """Find the PID of the running Cursor application.""" - workspace = NSWorkspace.sharedWorkspace() - for app in workspace.runningApplications(): - name = app.localizedName() - bundle = app.bundleIdentifier() or "" - if name == "Cursor" or "cursor" in bundle.lower(): - return app.processIdentifier() - return None - - -def dump_element(element, depth: int = 0, max_depth: int = 5) -> None: - """Recursively print an AXUIElement's attributes.""" - if depth > max_depth: - return - - indent = " " * depth - names_err, attr_names = AXUIElementCopyAttributeNames(element, None) - if names_err or not attr_names: - return - - role = "" - title = "" - value = "" - description = "" - - for name in attr_names: - err, val = AXUIElementCopyAttributeValue(element, name, None) - if err: - continue - if name == "AXRole": - role = str(val) - elif name == "AXTitle": - title = str(val) if val else "" - elif name == "AXValue": - value_str = str(val)[:100] if val else "" - value = value_str - elif name == "AXDescription": - description = str(val) if val else "" - - label = role - if title: - label += f' title="{title}"' - if description: - label += f' desc="{description}"' - if value: - label += f' value="{value}"' - - print(f"{indent}{label}") - - # Recurse into children - err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None) - if not err and children: - for child in children: - dump_element(child, depth + 1, max_depth) - - -def main(): - parser = argparse.ArgumentParser(description="Dump Cursor's accessibility tree") - parser.add_argument("--depth", type=int, default=8, help="Max depth to traverse") - args = parser.parse_args() - - pid = find_cursor_pid() - if pid is None: - print("Cursor is not running.", file=sys.stderr) - sys.exit(1) - - print(f"Found Cursor at PID {pid}") - app_element = AXUIElementCreateApplication(pid) - dump_element(app_element, max_depth=args.depth) - - -if __name__ == "__main__": - main() -``` - -**Step 2: Run it with Cursor open** - -Run: `python scripts/dump_a11y_tree.py --depth 6` -Expected: prints a tree of accessibility elements. Look for button elements with titles like "Accept", "Reject", "Stop", etc. Note the tree path to these elements. - -**Step 3: Document findings** - -After running, note the element paths and patterns in a comment at the top of the detector module (next task). This is exploratory — the actual patterns will depend on what we find. - -**Step 4: Commit** - -```bash -git add scripts/ -git commit -m "feat: add accessibility tree explorer script for development" -``` - ---- - -### Task 5: Accessibility Detector Module - -**Files:** -- Create: `src/cursor_flasher/detector.py` -- Create: `tests/test_detector.py` - -**Step 1: Write the failing tests** - -We can't fully test accessibility APIs without a running Cursor instance, so we test the logic layer with mocked a11y data. - -`tests/test_detector.py`: -```python -import pytest -from unittest.mock import patch, MagicMock -from cursor_flasher.detector import ( - CursorDetector, - parse_ui_signals, - UISignals, -) - - -class TestParseUISignals: - def test_no_elements_means_no_signals(self): - signals = parse_ui_signals([]) - assert signals.agent_working is False - assert signals.approval_needed is False - - def test_stop_button_means_agent_working(self): - elements = [{"role": "AXButton", "title": "Stop"}] - signals = parse_ui_signals(elements) - assert signals.agent_working is True - - def test_accept_button_means_approval_needed(self): - elements = [{"role": "AXButton", "title": "Accept"}] - signals = parse_ui_signals(elements) - assert signals.approval_needed is True - - def test_reject_button_means_approval_needed(self): - elements = [{"role": "AXButton", "title": "Reject"}] - signals = parse_ui_signals(elements) - assert signals.approval_needed is True - - def test_both_signals(self): - elements = [ - {"role": "AXButton", "title": "Stop"}, - {"role": "AXButton", "title": "Accept"}, - ] - signals = parse_ui_signals(elements) - assert signals.agent_working is True - assert signals.approval_needed is True - - def test_irrelevant_buttons_ignored(self): - elements = [{"role": "AXButton", "title": "Settings"}] - signals = parse_ui_signals(elements) - assert signals.agent_working is False - assert signals.approval_needed is False - - -class TestCursorDetector: - def test_returns_none_when_cursor_not_running(self): - detector = CursorDetector() - with patch.object(detector, "_find_cursor_pid", return_value=None): - signals = detector.poll() - assert signals is None -``` - -**Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_detector.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement detector module** - -`src/cursor_flasher/detector.py`: -```python -"""Accessibility-based detection of Cursor's agent state.""" -from dataclasses import dataclass - -from ApplicationServices import ( - AXUIElementCreateApplication, - AXUIElementCopyAttributeNames, - AXUIElementCopyAttributeValue, -) -from Cocoa import NSWorkspace - - -AGENT_WORKING_TITLES = {"Stop", "Cancel generating"} -APPROVAL_TITLES = {"Accept", "Reject", "Accept All", "Deny"} - - -@dataclass -class UISignals: - agent_working: bool = False - approval_needed: bool = False - - -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: - if el.get("role") != "AXButton": - continue - title = el.get("title", "") - if title in AGENT_WORKING_TITLES: - agent_working = True - if title in APPROVAL_TITLES: - 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_buttons(app_element, max_depth=8) - return parse_ui_signals(elements) - - def _find_cursor_pid(self) -> int | None: - workspace = NSWorkspace.sharedWorkspace() - for app in workspace.runningApplications(): - name = app.localizedName() - bundle = app.bundleIdentifier() or "" - if name == "Cursor" or "cursor" in bundle.lower(): - return app.processIdentifier() - return None - - def _collect_buttons( - self, element, max_depth: int = 8, depth: int = 0 - ) -> list[dict]: - """Walk the a11y tree collecting button elements.""" - if depth > max_depth: - return [] - - results = [] - err, attr_names = AXUIElementCopyAttributeNames(element, None) - if err or not attr_names: - return results - - role = "" - title = "" - - 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 "" - - if 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_buttons(child, max_depth, depth + 1)) - - return results -``` - -**Step 4: Run tests to verify they pass** - -Run: `pytest tests/test_detector.py -v` -Expected: all PASS (mocked tests pass; the `CursorDetector` test with mocked `_find_cursor_pid` passes) - -**Step 5: Commit** - -```bash -git add -A -git commit -m "feat: add accessibility-based agent state detector" -``` - ---- - -### Task 6: Overlay Window - -**Files:** -- Create: `src/cursor_flasher/overlay.py` - -This module is heavily macOS-native and difficult to unit test. We build it and test manually. - -**Step 1: Implement the overlay** - -`src/cursor_flasher/overlay.py`: -```python -"""Native macOS overlay window that draws a pulsing border around a target window.""" -import objc -from Cocoa import ( - NSApplication, - NSWindow, - NSBorderlessWindowMask, - NSColor, - NSView, - NSBezierPath, - NSTimer, - NSScreen, -) -from Quartz import ( - CGWindowListCopyWindowInfo, - kCGWindowListOptionOnScreenOnly, - kCGNullWindowID, - kCGWindowOwnerPID, - kCGWindowBounds, - kCGWindowLayer, -) -import math - -from cursor_flasher.config import Config - - -def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor: - """Convert a hex color string to NSColor.""" - hex_str = hex_str.lstrip("#") - r = int(hex_str[0:2], 16) / 255.0 - g = int(hex_str[2:4], 16) / 255.0 - b = int(hex_str[4:6], 16) / 255.0 - return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha) - - -class PulseBorderView(NSView): - """Custom view that draws a pulsing border rectangle.""" - - def initWithFrame_config_(self, frame, config): - self = objc.super(PulseBorderView, self).initWithFrame_(frame) - if self is None: - return None - self._config = config - self._phase = 0.0 - return self - - def drawRect_(self, rect): - opacity_range = self._config.pulse_opacity_max - self._config.pulse_opacity_min - alpha = self._config.pulse_opacity_min + opacity_range * ( - 0.5 + 0.5 * math.sin(self._phase) - ) - - color = hex_to_nscolor(self._config.pulse_color, alpha) - color.setStroke() - - width = self._config.pulse_width - inset = width / 2.0 - path = NSBezierPath.bezierPathWithRect_(self.bounds().insetBy(inset, inset)) - path.setLineWidth_(width) - path.stroke() - - def setPhase_(self, phase): - self._phase = phase - self.setNeedsDisplay_(True) - - # Helper for insetBy since NSRect doesn't have it - def bounds(self): - b = objc.super(PulseBorderView, self).bounds() - return b - - -class OverlayWindow: - """Manages the overlay window lifecycle.""" - - def __init__(self, config: Config): - self._config = config - self._window: NSWindow | None = None - self._view: PulseBorderView | None = None - self._timer: NSTimer | None = None - self._phase = 0.0 - self._target_pid: int | None = None - - def show(self, pid: int) -> None: - """Show the pulsing overlay around the window belonging to `pid`.""" - self._target_pid = pid - frame = self._get_window_frame(pid) - if frame is None: - return - - if self._window is None: - self._create_window(frame) - else: - self._window.setFrame_display_(frame, True) - - self._window.orderFrontRegardless() - self._start_animation() - - def hide(self) -> None: - """Hide and clean up the overlay.""" - self._stop_animation() - if self._window is not None: - self._window.orderOut_(None) - - def update_position(self) -> None: - """Reposition overlay to match current Cursor window position.""" - if self._target_pid is None or self._window is None: - return - frame = self._get_window_frame(self._target_pid) - if frame is not None: - self._window.setFrame_display_(frame, True) - - def _create_window(self, frame) -> None: - self._window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( - frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered - ) - self._window.setOpaque_(False) - self._window.setBackgroundColor_(NSColor.clearColor()) - self._window.setLevel_(25) # NSStatusWindowLevel — above normal windows - self._window.setIgnoresMouseEvents_(True) - self._window.setHasShadow_(False) - - self._view = PulseBorderView.alloc().initWithFrame_config_( - frame, self._config - ) - self._window.setContentView_(self._view) - - def _start_animation(self) -> None: - if self._timer is not None: - return - interval = 1.0 / 30.0 # 30 fps - self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( - interval, self, "_tick:", None, True - ) - - def _stop_animation(self) -> None: - if self._timer is not None: - self._timer.invalidate() - self._timer = None - self._phase = 0.0 - - @objc.python_method - def _tick_impl(self): - speed = self._config.pulse_speed - step = (2.0 * math.pi) / (speed * 30.0) - self._phase += step - if self._view is not None: - self._view.setPhase_(self._phase) - self.update_position() - - def _tick_(self, timer) -> None: - self._tick_impl() - - def _get_window_frame(self, pid: int): - """Get the screen frame of the main window for the given PID.""" - window_list = CGWindowListCopyWindowInfo( - kCGWindowListOptionOnScreenOnly, kCGNullWindowID - ) - if not window_list: - return None - - screen_height = NSScreen.mainScreen().frame().size.height - - for info in window_list: - if info.get(kCGWindowOwnerPID) != pid: - continue - if info.get(kCGWindowLayer, 999) != 0: - continue - bounds = info.get(kCGWindowBounds) - if bounds is None: - continue - # Convert from CG coordinates (top-left origin) to NS coordinates (bottom-left origin) - x = bounds["X"] - y = screen_height - bounds["Y"] - bounds["Height"] - w = bounds["Width"] - h = bounds["Height"] - return ((x, y), (w, h)) - - return None -``` - -**Step 2: Create a quick manual test script** - -`scripts/test_overlay.py`: -```python -"""Manual test: shows a pulsing border around Cursor for 10 seconds.""" -import sys -import time -from Cocoa import NSApplication, NSRunLoop, NSDate -from cursor_flasher.config import Config -from cursor_flasher.overlay import OverlayWindow -from cursor_flasher.detector import CursorDetector - -app = NSApplication.sharedApplication() - -config = Config() -overlay = OverlayWindow(config) -detector = CursorDetector() - -pid = detector._find_cursor_pid() -if pid is None: - print("Cursor not running") - sys.exit(1) - -print(f"Showing overlay for PID {pid} for 10 seconds...") -overlay.show(pid) - -end_time = time.time() + 10 -while time.time() < end_time: - NSRunLoop.currentRunLoop().runUntilDate_( - NSDate.dateWithTimeIntervalSinceNow_(0.1) - ) - -overlay.hide() -print("Done.") -``` - -**Step 3: Test manually** - -Run: `python scripts/test_overlay.py` -Expected: A pulsing amber border appears around the Cursor window for 10 seconds. - -**Step 4: Commit** - -```bash -git add -A -git commit -m "feat: add native macOS overlay window with pulsing border" -``` - ---- - -### Task 7: Sound Module - -**Files:** -- Create: `src/cursor_flasher/sound.py` -- Create: `tests/test_sound.py` - -**Step 1: Write the failing test** - -`tests/test_sound.py`: -```python -import pytest -from unittest.mock import patch, MagicMock -from cursor_flasher.sound import play_alert -from cursor_flasher.config import Config - - -class TestPlayAlert: - def test_does_nothing_when_disabled(self): - config = Config(sound_enabled=False) - # Should not raise - play_alert(config) - - @patch("cursor_flasher.sound.NSSound") - def test_plays_named_sound(self, mock_nssound): - mock_sound_obj = MagicMock() - mock_nssound.soundNamed_.return_value = mock_sound_obj - config = Config(sound_enabled=True, sound_name="Glass", sound_volume=0.7) - play_alert(config) - mock_nssound.soundNamed_.assert_called_once_with("Glass") - mock_sound_obj.setVolume_.assert_called_once_with(0.7) - mock_sound_obj.play.assert_called_once() -``` - -**Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_sound.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement sound module** - -`src/cursor_flasher/sound.py`: -```python -"""System sound playback.""" -from Cocoa import NSSound - -from cursor_flasher.config import Config - - -def play_alert(config: Config) -> None: - """Play the configured alert sound if enabled.""" - if not config.sound_enabled: - return - - sound = NSSound.soundNamed_(config.sound_name) - if sound is None: - return - - sound.setVolume_(config.sound_volume) - sound.play() -``` - -**Step 4: Run tests to verify they pass** - -Run: `pytest tests/test_sound.py -v` -Expected: all PASS - -**Step 5: Commit** - -```bash -git add -A -git commit -m "feat: add system sound alert module" -``` - ---- - -### Task 8: Main Daemon Loop - -**Files:** -- Create: `src/cursor_flasher/daemon.py` - -**Step 1: Implement the daemon** - -`src/cursor_flasher/daemon.py`: -```python -"""Main daemon loop that ties detection, state machine, overlay, and sound together.""" -import logging -import signal -import sys -import time - -from Cocoa import NSApplication, NSRunLoop, NSDate - -from cursor_flasher.config import Config, load_config -from cursor_flasher.detector import CursorDetector -from cursor_flasher.overlay import OverlayWindow -from cursor_flasher.sound import play_alert -from cursor_flasher.state import StateMachine, FlasherState - -logger = logging.getLogger("cursor_flasher") - - -class FlasherDaemon: - def __init__(self, config: Config): - self.config = config - self.state_machine = StateMachine(cooldown=config.cooldown) - self.detector = CursorDetector() - self.overlay = OverlayWindow(config) - self._running = False - self._waiting_since: float | None = None - - def run(self) -> None: - """Run the main loop. Blocks until stopped.""" - app = NSApplication.sharedApplication() - self._running = True - - signal.signal(signal.SIGTERM, self._handle_signal) - signal.signal(signal.SIGINT, self._handle_signal) - - logger.info("Cursor Flasher daemon started") - - while self._running: - self._tick() - NSRunLoop.currentRunLoop().runUntilDate_( - NSDate.dateWithTimeIntervalSinceNow_(self.config.poll_interval) - ) - - self.overlay.hide() - logger.info("Cursor Flasher daemon stopped") - - def stop(self) -> None: - self._running = False - - def _tick(self) -> None: - signals = self.detector.poll() - - if signals is None: - if self.state_machine.state == FlasherState.WAITING_FOR_USER: - self.state_machine.dismiss() - self.overlay.hide() - self._waiting_since = None - return - - changed = self.state_machine.update( - agent_working=signals.agent_working, - approval_needed=signals.approval_needed, - ) - - if not changed: - if ( - self.state_machine.state == FlasherState.WAITING_FOR_USER - and self._waiting_since is not None - ): - elapsed = time.monotonic() - self._waiting_since - if elapsed > self.config.auto_dismiss: - self.state_machine.dismiss() - self.overlay.hide() - self._waiting_since = None - return - - match self.state_machine.state: - case FlasherState.WAITING_FOR_USER: - pid = self.detector._pid - if pid is not None: - self.overlay.show(pid) - play_alert(self.config) - self._waiting_since = time.monotonic() - case FlasherState.AGENT_WORKING | FlasherState.IDLE: - self.overlay.hide() - self._waiting_since = None - - def _handle_signal(self, signum, frame): - logger.info(f"Received signal {signum}, shutting down") - self.stop() - - -def run_daemon() -> None: - """Entry point for the daemon.""" - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", - ) - config = load_config() - daemon = FlasherDaemon(config) - daemon.run() -``` - -**Step 2: Verify it loads without errors** - -Run: `python -c "from cursor_flasher.daemon import FlasherDaemon; print('OK')"` -Expected: `OK` - -**Step 3: Commit** - -```bash -git add -A -git commit -m "feat: add main daemon loop wiring detection, overlay, and sound" -``` - ---- - -### Task 9: CLI - -**Files:** -- Create: `src/cursor_flasher/cli.py` - -**Step 1: Implement the CLI** - -`src/cursor_flasher/cli.py`: -```python -"""CLI for starting/stopping the cursor-flasher daemon.""" -import argparse -import logging -import os -import signal -import sys -from pathlib import Path - -PID_FILE = Path.home() / ".cursor-flasher" / "daemon.pid" - - -def _write_pid() -> None: - PID_FILE.parent.mkdir(parents=True, exist_ok=True) - PID_FILE.write_text(str(os.getpid())) - - -def _read_pid() -> int | None: - if not PID_FILE.exists(): - return None - try: - pid = int(PID_FILE.read_text().strip()) - os.kill(pid, 0) # Check if process is alive - return pid - except (ValueError, ProcessLookupError, PermissionError): - PID_FILE.unlink(missing_ok=True) - return None - - -def _remove_pid() -> None: - PID_FILE.unlink(missing_ok=True) - - -def cmd_start(args: argparse.Namespace) -> None: - existing = _read_pid() - if existing is not None: - print(f"Daemon already running (PID {existing})") - sys.exit(1) - - if args.foreground: - _write_pid() - try: - from cursor_flasher.daemon import run_daemon - run_daemon() - finally: - _remove_pid() - else: - pid = os.fork() - if pid > 0: - print(f"Daemon started (PID {pid})") - return - # Child process - os.setsid() - _write_pid() - try: - from cursor_flasher.daemon import run_daemon - run_daemon() - finally: - _remove_pid() - - -def cmd_stop(args: argparse.Namespace) -> None: - pid = _read_pid() - if pid is None: - print("Daemon is not running") - sys.exit(1) - - os.kill(pid, signal.SIGTERM) - _remove_pid() - print(f"Daemon stopped (PID {pid})") - - -def cmd_status(args: argparse.Namespace) -> None: - pid = _read_pid() - if pid is None: - print("Daemon is not running") - else: - print(f"Daemon is running (PID {pid})") - - -def main() -> None: - parser = argparse.ArgumentParser( - prog="cursor-flasher", - description="Flash the Cursor window when the AI agent is waiting for input", - ) - sub = parser.add_subparsers(dest="command") - - start_parser = sub.add_parser("start", help="Start the daemon") - start_parser.add_argument( - "--foreground", "-f", action="store_true", - help="Run in the foreground (don't daemonize)" - ) - start_parser.set_defaults(func=cmd_start) - - stop_parser = sub.add_parser("stop", help="Stop the daemon") - stop_parser.set_defaults(func=cmd_stop) - - status_parser = sub.add_parser("status", help="Check daemon status") - status_parser.set_defaults(func=cmd_status) - - args = parser.parse_args() - if not hasattr(args, "func"): - parser.print_help() - sys.exit(1) - - args.func(args) -``` - -**Step 2: Reinstall and test CLI** - -Run: `pip install -e ".[dev]"` then `cursor-flasher --help` -Expected: prints usage info with start/stop/status commands - -**Step 3: Test foreground mode briefly** - -Run: `cursor-flasher start --foreground` (then Ctrl+C after a few seconds) -Expected: starts monitoring, logs output, stops cleanly on Ctrl+C - -**Step 4: Commit** - -```bash -git add -A -git commit -m "feat: add CLI with start/stop/status commands" -``` - ---- - -### Task 10: Integration Testing & Tuning - -**Files:** -- Modify: `src/cursor_flasher/detector.py` (tune button titles based on a11y dump) - -**Step 1: Run the a11y tree dump** - -Run: `python scripts/dump_a11y_tree.py --depth 8 > a11y_dump.txt` -Expected: dumps Cursor's full accessibility tree to a file - -**Step 2: Search for relevant buttons** - -Examine `a11y_dump.txt` for button titles. Look for patterns like "Accept", "Reject", "Stop", "Cancel", or any loading/thinking indicators. Update the `AGENT_WORKING_TITLES` and `APPROVAL_TITLES` sets in `detector.py` based on findings. - -**Step 3: Run end-to-end test** - -Run: `cursor-flasher start --foreground` -Then trigger an agent action in Cursor and wait for it to finish. Verify: -- Overlay appears when agent finishes -- Sound plays -- Overlay disappears when you interact - -**Step 4: Tune and fix any issues** - -Iterate on detection patterns, overlay positioning, and timing based on real-world testing. - -**Step 5: Commit** - -```bash -git add -A -git commit -m "fix: tune detection patterns based on accessibility tree analysis" -``` - ---- - -### Task 11: README - -**Files:** -- Create: `README.md` - -**Step 1: Write the README** - -`README.md` should cover: -- What cursor-flasher does (one sentence) -- Prerequisites (macOS, Python 3.10+, Accessibility permission) -- Installation steps -- Usage (start/stop/status) -- Configuration (link to config.yaml format) -- Troubleshooting (Accessibility permissions) - -**Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: add README with installation and usage instructions" -``` diff --git a/pyproject.toml b/pyproject.toml index 744a0f7..a83de6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ build-backend = "setuptools.build_meta" name = "cursor-flasher" version = "0.2.0" description = "Flash Cursor's window when the AI agent needs attention" +readme = "README.md" +license = "MIT" +authors = [{ name = "cottongin" }] requires-python = ">=3.10" dependencies = [ "pyobjc-framework-applicationservices>=12.1", @@ -17,6 +20,9 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest", "pytest-mock"] +[project.urls] +Repository = "https://code.cottongin.xyz/cursor-flasher" + [project.scripts] cursor-flasher = "cursor_flasher.cli:main" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a4f21dc..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pyobjc-framework-ApplicationServices -pyobjc-framework-Cocoa -pyobjc-framework-Quartz -PyYAML -pytest -pytest-mock