Restructure config for per-mode style/sound and fix pulse dismiss
Major changes: - Add StyleConfig dataclass with independent color, width, opacity, duration, pulse_speed, sound, and volume per mode (running/completed) - Replace flat flash_*/sound_*/play_on config with running: and completed: YAML sections - Replace CGEventTap (silently fails in forked daemon) with CGEventSourceSecondsSinceLastEventType polling for reliable input-based pulse dismissal when Cursor is already frontmost - Update overlay, sound, and daemon to pass StyleConfig per call - Rewrite tests for new config shape and dismiss mechanism Made-with: Cursor
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ build/
|
||||
*.egg
|
||||
.venv/
|
||||
a11y_dump.txt
|
||||
agent-tools/
|
||||
|
||||
145
README.md
145
README.md
@@ -1,111 +1,104 @@
|
||||
# cursor-flasher
|
||||
|
||||
A macOS daemon that flashes a pulsing border around Cursor IDE windows when the AI agent is waiting for your input. Optionally plays a system sound.
|
||||
Flash a colored border on the Cursor IDE window when the AI agent needs your attention — tool approval, questions, or task completion.
|
||||
|
||||
## How It Works
|
||||
|
||||
Uses [Cursor hooks](https://cursor.com/docs/agent/hooks) for reliable detection:
|
||||
|
||||
- **`preToolUse`** — fires when the agent wants to run a shell command, write a file, or use any tool that may need approval. **Pulses** the border continuously and plays a sound until you click the Cursor window.
|
||||
- **`stop`** — fires when the agent loop ends. **Flashes** the border once, briefly.
|
||||
|
||||
Only tools in the `approval_tools` list trigger the pulse (default: `Shell`, `Write`, `Delete`). Auto-approved tools like `Read` and `Grep` are ignored.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS
|
||||
- Python 3.10+
|
||||
- [uv](https://docs.astral.sh/uv/) (recommended)
|
||||
- **Accessibility permission** granted to your terminal (System Settings → Privacy & Security → Accessibility)
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- Cursor IDE
|
||||
- Accessibility permission for your terminal (System Settings → Privacy & Security → Accessibility)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone <repo-url> && cd cursor-flasher
|
||||
uv sync
|
||||
|
||||
# Install Cursor hooks (global, applies to all projects)
|
||||
uv run cursor-flasher install
|
||||
|
||||
# Start the daemon
|
||||
uv run cursor-flasher start
|
||||
```
|
||||
|
||||
The `install` command copies the hook script to `~/.cursor/hooks/` and adds entries to `~/.cursor/hooks.json`. Cursor auto-reloads hooks.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Verify accessibility permissions work from your terminal
|
||||
uv run cursor-flasher check
|
||||
|
||||
# Start the daemon (backgrounds automatically)
|
||||
uv run cursor-flasher start
|
||||
|
||||
# Start in foreground (useful for debugging)
|
||||
uv run cursor-flasher start --foreground
|
||||
|
||||
# Check if the daemon is running
|
||||
uv run cursor-flasher start # background daemon
|
||||
uv run cursor-flasher start --foreground # foreground (for debugging)
|
||||
uv run cursor-flasher status
|
||||
|
||||
# Stop the daemon
|
||||
uv run cursor-flasher stop
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Polls Cursor's macOS accessibility tree every 500ms
|
||||
2. Detects agent state by looking for specific UI elements (Stop/Accept/Reject buttons)
|
||||
3. When the agent finishes and is waiting for input, shows a pulsing amber border around **only the window(s) that need attention**
|
||||
4. Plays a system sound (default: "Glass")
|
||||
5. Dismisses automatically when the agent starts working again, or after a timeout
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.cursor-flasher/config.yaml` to customize:
|
||||
Optional config file at `~/.cursor-flasher/config.yaml`:
|
||||
|
||||
```yaml
|
||||
pulse:
|
||||
color: "#FF9500" # Border color (hex)
|
||||
width: 4 # Border thickness (px)
|
||||
speed: 1.5 # Pulse cycle duration (seconds)
|
||||
opacity_min: 0.3 # Minimum pulse opacity
|
||||
opacity_max: 1.0 # Maximum pulse opacity
|
||||
running: # approval pulse (continuous until you interact)
|
||||
color: "#FF9500" # border color (hex)
|
||||
width: 4 # border thickness in pixels
|
||||
opacity: 0.85 # max border opacity
|
||||
pulse_speed: 1.5 # pulse cycle speed in seconds
|
||||
sound: "Glass" # macOS system sound ("" to disable)
|
||||
volume: 0.5 # 0.0 to 1.0
|
||||
# sounds: Basso, Blow, Bottle, Frog, Funk, Glass, Hero,
|
||||
# Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
|
||||
|
||||
sound:
|
||||
enabled: true # Play sound on trigger
|
||||
name: "Glass" # macOS system sound name
|
||||
volume: 0.5 # Volume (0.0 - 1.0)
|
||||
completed: # agent stop flash (brief fade-in/out)
|
||||
color: "#00FF00" # different color for completion
|
||||
width: 4
|
||||
opacity: 0.85
|
||||
duration: 1.5 # flash duration in seconds
|
||||
sound: "" # no sound by default (Cursor plays its own)
|
||||
volume: 0.0
|
||||
|
||||
detection:
|
||||
poll_interval: 0.5 # Seconds between accessibility tree polls
|
||||
cooldown: 3.0 # Seconds before re-triggering after dismissal
|
||||
flash:
|
||||
mode: "screen" # "window", "screen", or "allscreens"
|
||||
|
||||
timeout:
|
||||
auto_dismiss: 300 # Auto-hide overlay after N seconds
|
||||
# Tools that trigger the pulse + sound (approval mode).
|
||||
# Others are silently ignored (e.g., Read, Grep, Glob, Task).
|
||||
approval_tools:
|
||||
- Shell
|
||||
- Write
|
||||
- Delete
|
||||
|
||||
general:
|
||||
approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals)
|
||||
cooldown: 2.0 # minimum seconds between flashes
|
||||
```
|
||||
|
||||
All values are optional — defaults are used for anything not specified.
|
||||
Each mode (`running` and `completed`) has its own color, border style, and sound settings. Set `sound: ""` to disable sound for a particular mode.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
uv run cursor-flasher uninstall
|
||||
uv run cursor-flasher stop
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Cannot read accessibility tree" / no detection
|
||||
**Flashing on every tool call (too noisy):**
|
||||
- Edit `~/.cursor-flasher/config.yaml` and narrow down `approval_tools` to just `Shell`.
|
||||
|
||||
This is almost always an Accessibility permission issue. Run:
|
||||
**No flash at all:**
|
||||
- Check daemon: `uv run cursor-flasher status`
|
||||
- Check hooks installed: `ls ~/.cursor/hooks/cursor-flasher-notify.py`
|
||||
- Check Cursor Settings → Hooks tab for execution logs
|
||||
|
||||
```bash
|
||||
uv run cursor-flasher check
|
||||
```
|
||||
|
||||
If it reports a failure, your terminal app needs Accessibility permission:
|
||||
|
||||
1. Open **System Settings → Privacy & Security → Accessibility**
|
||||
2. Click the **+** button and add your terminal app (Terminal.app, Ghostty, iTerm2, etc.)
|
||||
3. Restart the terminal after granting permission
|
||||
|
||||
### Cursor not detected
|
||||
|
||||
Make sure Cursor is running. The daemon identifies it by bundle ID (`com.todesktop.230313mzl4w4u92`).
|
||||
|
||||
### Overlay appears on wrong windows
|
||||
|
||||
Detection is per-window — only windows with active approval prompts (Accept, Reject, Run, etc.) should flash. If you see false positives, the detection patterns may need tuning for your Cursor version. Use `scripts/dump_a11y_tree.py` to inspect what the accessibility tree looks like.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
uv run pytest tests/ -v
|
||||
|
||||
# Dump Cursor's accessibility tree (for debugging detection)
|
||||
uv run python scripts/dump_a11y_tree.py --depth 8
|
||||
|
||||
# Manual overlay test (flashes all windows for 10 seconds)
|
||||
uv run python scripts/test_overlay.py
|
||||
|
||||
# Manual overlay test (only windows needing attention)
|
||||
uv run python scripts/test_overlay.py --per-window
|
||||
```
|
||||
**Pulse doesn't stop:**
|
||||
- Click the Cursor window to bring it to focus — the pulse auto-dismisses when Cursor is the frontmost app.
|
||||
|
||||
33
chat-summaries/2026-03-10_03-59-summary.md
Normal file
33
chat-summaries/2026-03-10_03-59-summary.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Rewrite: Hook-based detection architecture
|
||||
|
||||
## Task
|
||||
Replaced the unreliable macOS accessibility tree polling approach with Cursor's native hooks API for detecting when the agent needs user attention.
|
||||
|
||||
## Problem
|
||||
The a11y-based approach had fundamental issues:
|
||||
- Cursor's "Stop" button during agent generation is NOT exposed in the accessibility tree
|
||||
- Approval button text persists in chat history, causing false positives
|
||||
- Required complex baseline tracking and state machine to mitigate, still unreliable
|
||||
|
||||
## Solution
|
||||
Switched to Cursor's lifecycle hooks (`preToolUse`, `stop`) which fire directly from Cursor when:
|
||||
- The agent wants to use a tool (shell command, file write, etc.) that may need approval
|
||||
- The agent loop completes (task finished, waiting for next prompt)
|
||||
|
||||
Architecture: Hook script → Unix domain socket → Daemon → Window flash overlay
|
||||
|
||||
## Changes
|
||||
- **New**: `hooks/notify.py` — hook script that sends workspace/event info to daemon socket
|
||||
- **New**: `src/cursor_flasher/windows.py` — window discovery and geometry (a11y used only for positions)
|
||||
- **Rewritten**: `src/cursor_flasher/daemon.py` — Unix socket listener instead of a11y polling
|
||||
- **Rewritten**: `src/cursor_flasher/overlay.py` — single brief flash instead of continuous pulse
|
||||
- **Rewritten**: `src/cursor_flasher/cli.py` — added `install`/`uninstall` commands for hook management
|
||||
- **Rewritten**: `src/cursor_flasher/config.py` — simplified config (no sound, no polling settings)
|
||||
- **Deleted**: `detector.py`, `state.py`, `sound.py`, `scripts/` directory
|
||||
- **Rewritten**: All tests for new architecture (13 tests passing)
|
||||
- **Updated**: `README.md`, `.gitignore`, `pyproject.toml` (bumped to 0.2.0)
|
||||
|
||||
## Follow-up
|
||||
- The daemon is running and hooks are installed globally at `~/.cursor/hooks.json`
|
||||
- Hooks fire on every `preToolUse` — could add matcher filtering if too noisy
|
||||
- No sound from cursor-flasher; relies on Cursor's built-in sound
|
||||
19
chat-summaries/2026-03-10_04-08-summary.md
Normal file
19
chat-summaries/2026-03-10_04-08-summary.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Tweaks: approval filtering + two flash modes
|
||||
|
||||
## Task
|
||||
Added tool filtering and differentiated flash behavior for approval vs. completion events.
|
||||
|
||||
## Changes
|
||||
- **`config.py`**: Added `approval_tools` list (default: Shell, Write, Delete), `sound_name`/`sound_volume`, `pulse_speed`
|
||||
- **`overlay.py`**: Two modes — `pulse()` with sine-wave animation + auto-dismiss when Cursor is focused, `flash()` for brief single flash
|
||||
- **`daemon.py`**: Routes `preToolUse` to pulse+sound (filtered by approval_tools), `stop` to brief flash; stop dismisses active pulse
|
||||
- **`sound.py`**: Re-added for approval pulse events
|
||||
- **Tests**: 19 tests covering tool filtering, both flash modes, cooldown, fallback behavior
|
||||
- **`README.md`**: Documented both behaviors and config options
|
||||
|
||||
## Behavior
|
||||
| Event | Tool in approval_tools? | Action |
|
||||
|-------|------------------------|--------|
|
||||
| preToolUse | Yes (Shell, Write, Delete) | Pulse border + play sound until user clicks window |
|
||||
| preToolUse | No (Read, Grep, etc.) | Ignored |
|
||||
| stop | N/A | Brief single flash |
|
||||
18
chat-summaries/2026-03-10_06-33-summary.md
Normal file
18
chat-summaries/2026-03-10_06-33-summary.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Per-mode Style and Sound Config
|
||||
|
||||
## Task
|
||||
Restructure the cursor-flasher config to have independent visual style and sound settings for "running" (approval pulse) and "completed" (agent stop flash), replacing the shared flat config and `play_on` field.
|
||||
|
||||
## Changes
|
||||
|
||||
- **`src/cursor_flasher/config.py`** — Added `StyleConfig` dataclass (`color`, `width`, `opacity`, `duration`, `pulse_speed`, `sound`, `volume`). `Config` now has `running: StyleConfig` and `completed: StyleConfig` with sensible defaults. Removed flat `flash_*`, `sound_*`, and `play_on` fields. Updated `load_config` to parse `running:` and `completed:` YAML sections.
|
||||
- **`src/cursor_flasher/overlay.py`** — `flash()` and `pulse()` accept a `StyleConfig` parameter. `OverlayManager.__init__` no longer takes `Config`.
|
||||
- **`src/cursor_flasher/sound.py`** — `play_alert()` accepts `StyleConfig` instead of `Config`. Skips playback when `sound` is empty.
|
||||
- **`src/cursor_flasher/daemon.py`** — Passes `config.running` to pulse/play_alert, `config.completed` to flash/play_alert. Removed `play_on` conditionals. `OverlayManager()` instantiated without args.
|
||||
- **`tests/test_config.py`** — Rewritten for new config structure (13 tests).
|
||||
- **`tests/test_daemon.py`** — Updated overlay call assertions to include StyleConfig arg. Replaced `play_on` tests with per-style sound tests. Added `test_custom_colors_per_mode`. 40/40 pass.
|
||||
- **`README.md`** — Updated config docs to show `running:` / `completed:` YAML structure.
|
||||
|
||||
## Follow-up
|
||||
- Restart daemon to pick up new config structure
|
||||
- Update user's `~/.cursor-flasher/config.yaml` if it uses the old `flash:`/`sound:` format
|
||||
40
hooks/notify.py
Executable file
40
hooks/notify.py
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cursor hook script that notifies the cursor-flasher daemon via Unix socket.
|
||||
|
||||
Installed as a Cursor hook (preToolUse, stop) to trigger a window flash
|
||||
when the agent needs user attention. Reads hook JSON from stdin, extracts
|
||||
workspace and event info, and sends it to the daemon's socket.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
|
||||
workspace_roots = data.get("workspace_roots") or []
|
||||
workspace = workspace_roots[0] if workspace_roots else ""
|
||||
event = data.get("hook_event_name", "")
|
||||
tool = data.get("tool_name", "")
|
||||
|
||||
msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(1)
|
||||
s.connect(SOCKET_PATH)
|
||||
s.sendall(msg.encode())
|
||||
s.close()
|
||||
except (ConnectionRefusedError, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "cursor-flasher"
|
||||
version = "0.1.0"
|
||||
description = "Flash Cursor's window when the AI agent is waiting for input"
|
||||
version = "0.2.0"
|
||||
description = "Flash Cursor's window when the AI agent needs attention"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"pyobjc-framework-applicationservices>=12.1",
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""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
|
||||
|
||||
|
||||
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
|
||||
|
||||
|
||||
def find_cursor_pid() -> int | None:
|
||||
"""Find the PID of the running Cursor application."""
|
||||
workspace = NSWorkspace.sharedWorkspace()
|
||||
for app in workspace.runningApplications():
|
||||
bundle = app.bundleIdentifier() or ""
|
||||
if bundle == CURSOR_BUNDLE_ID:
|
||||
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}")
|
||||
|
||||
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()
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Manual test: shows a pulsing border around Cursor windows for 10 seconds.
|
||||
|
||||
With no args, flashes all windows (for visual testing).
|
||||
With --per-window, only flashes windows with active signals (production behavior).
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ApplicationServices import (
|
||||
AXUIElementCreateApplication,
|
||||
AXUIElementCopyAttributeValue,
|
||||
)
|
||||
from Cocoa import NSApplication, NSRunLoop, NSDate
|
||||
|
||||
from cursor_flasher.config import Config
|
||||
from cursor_flasher.overlay import OverlayManager
|
||||
from cursor_flasher.detector import CursorDetector
|
||||
|
||||
app = NSApplication.sharedApplication()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--per-window", action="store_true",
|
||||
help="Only flash windows that need attention")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = Config()
|
||||
overlay = OverlayManager(config)
|
||||
detector = CursorDetector()
|
||||
|
||||
pid = detector._find_cursor_pid()
|
||||
if pid is None:
|
||||
print("Cursor not running")
|
||||
sys.exit(1)
|
||||
|
||||
if args.per_window:
|
||||
result = detector.poll()
|
||||
if result is None or not result.active_windows:
|
||||
print("No windows currently need attention")
|
||||
sys.exit(0)
|
||||
ax_windows = result.active_windows
|
||||
print(f"Flashing {len(ax_windows)} window(s) that need attention...")
|
||||
else:
|
||||
app_element = AXUIElementCreateApplication(pid)
|
||||
_, children = AXUIElementCopyAttributeValue(app_element, "AXChildren", None)
|
||||
ax_windows = []
|
||||
for child in children:
|
||||
_, role = AXUIElementCopyAttributeValue(child, "AXRole", None)
|
||||
if str(role) == "AXWindow":
|
||||
ax_windows.append(child)
|
||||
print(f"Flashing all {len(ax_windows)} Cursor window(s)...")
|
||||
|
||||
overlay.show(ax_windows)
|
||||
|
||||
end_time = time.time() + 10
|
||||
while time.time() < end_time:
|
||||
NSRunLoop.currentRunLoop().runUntilDate_(
|
||||
NSDate.dateWithTimeIntervalSinceNow_(0.1)
|
||||
)
|
||||
|
||||
overlay.hide()
|
||||
print("Done.")
|
||||
@@ -1,14 +1,32 @@
|
||||
"""CLI for starting/stopping the cursor-flasher daemon."""
|
||||
"""CLI for cursor-flasher: install hooks, start/stop daemon."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cursor_flasher.daemon import run_daemon
|
||||
from cursor_flasher.detector import check_accessibility
|
||||
|
||||
PID_FILE = Path.home() / ".cursor-flasher" / "daemon.pid"
|
||||
CURSOR_HOOKS_DIR = Path.home() / ".cursor" / "hooks"
|
||||
CURSOR_HOOKS_JSON = Path.home() / ".cursor" / "hooks.json"
|
||||
|
||||
HOOK_SCRIPT_NAME = "cursor-flasher-notify.py"
|
||||
|
||||
HOOKS_CONFIG = {
|
||||
"preToolUse": [
|
||||
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||
],
|
||||
"postToolUse": [
|
||||
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||
],
|
||||
"postToolUseFailure": [
|
||||
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||
],
|
||||
"stop": [
|
||||
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _write_pid() -> None:
|
||||
@@ -32,6 +50,85 @@ def _remove_pid() -> None:
|
||||
PID_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _find_hook_source() -> Path:
|
||||
"""Find the hook script source in the package."""
|
||||
pkg_dir = Path(__file__).resolve().parent.parent.parent
|
||||
return pkg_dir / "hooks" / "notify.py"
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
"""Install Cursor hooks for flash notifications."""
|
||||
CURSOR_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
src = _find_hook_source()
|
||||
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
|
||||
|
||||
if not src.exists():
|
||||
print(f"ERROR: Hook source not found at {src}")
|
||||
sys.exit(1)
|
||||
|
||||
shutil.copy2(src, dst)
|
||||
dst.chmod(0o755)
|
||||
print(f" Installed hook script: {dst}")
|
||||
|
||||
existing: dict = {}
|
||||
if CURSOR_HOOKS_JSON.exists():
|
||||
try:
|
||||
existing = json.loads(CURSOR_HOOKS_JSON.read_text())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
existing = {}
|
||||
|
||||
existing.setdefault("version", 1)
|
||||
hooks = existing.setdefault("hooks", {})
|
||||
|
||||
for event, entries in HOOKS_CONFIG.items():
|
||||
event_hooks = hooks.setdefault(event, [])
|
||||
for entry in entries:
|
||||
already = any(h.get("command") == entry["command"] for h in event_hooks)
|
||||
if not already:
|
||||
event_hooks.append(entry)
|
||||
|
||||
CURSOR_HOOKS_JSON.write_text(json.dumps(existing, indent=2) + "\n")
|
||||
print(f" Updated hooks config: {CURSOR_HOOKS_JSON}")
|
||||
print("\nInstallation complete. Cursor will auto-reload hooks.")
|
||||
print("Start the daemon with: cursor-flasher start")
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
"""Remove Cursor hooks."""
|
||||
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
print(f" Removed hook script: {dst}")
|
||||
|
||||
if CURSOR_HOOKS_JSON.exists():
|
||||
try:
|
||||
config = json.loads(CURSOR_HOOKS_JSON.read_text())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
config = {}
|
||||
|
||||
hooks = config.get("hooks", {})
|
||||
changed = False
|
||||
|
||||
for event in HOOKS_CONFIG:
|
||||
if event in hooks:
|
||||
before = len(hooks[event])
|
||||
hooks[event] = [
|
||||
h for h in hooks[event]
|
||||
if h.get("command") != f"./hooks/{HOOK_SCRIPT_NAME}"
|
||||
]
|
||||
if len(hooks[event]) < before:
|
||||
changed = True
|
||||
if not hooks[event]:
|
||||
del hooks[event]
|
||||
|
||||
if changed:
|
||||
CURSOR_HOOKS_JSON.write_text(json.dumps(config, indent=2) + "\n")
|
||||
print(f" Cleaned hooks config: {CURSOR_HOOKS_JSON}")
|
||||
|
||||
print("\nUninstall complete.")
|
||||
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> None:
|
||||
existing = _read_pid()
|
||||
if existing is not None:
|
||||
@@ -39,6 +136,8 @@ def cmd_start(args: argparse.Namespace) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
if args.foreground:
|
||||
from cursor_flasher.daemon import run_daemon
|
||||
|
||||
_write_pid()
|
||||
try:
|
||||
run_daemon()
|
||||
@@ -49,7 +148,10 @@ def cmd_start(args: argparse.Namespace) -> None:
|
||||
if pid > 0:
|
||||
print(f"Daemon started (PID {pid})")
|
||||
return
|
||||
|
||||
os.setsid()
|
||||
from cursor_flasher.daemon import run_daemon
|
||||
|
||||
_write_pid()
|
||||
try:
|
||||
run_daemon()
|
||||
@@ -76,29 +178,19 @@ def cmd_status(args: argparse.Namespace) -> None:
|
||||
print(f"Daemon is running (PID {pid})")
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace) -> None:
|
||||
info = check_accessibility()
|
||||
|
||||
if not info["cursor_running"]:
|
||||
print("FAIL Cursor is not running")
|
||||
sys.exit(1)
|
||||
print(f"OK Cursor found (PID {info['cursor_pid']})")
|
||||
|
||||
if not info["ax_accessible"]:
|
||||
print(f"FAIL {info['error']}")
|
||||
sys.exit(1)
|
||||
print(f"OK Accessibility tree readable ({info['window_count']} window(s))")
|
||||
|
||||
print("\nAll checks passed — cursor-flasher should work from this terminal.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cursor-flasher",
|
||||
description="Flash the Cursor window when the AI agent is waiting for input",
|
||||
description="Flash the Cursor window when the AI agent needs attention",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
install_parser = sub.add_parser("install", help="Install Cursor hooks")
|
||||
install_parser.set_defaults(func=cmd_install)
|
||||
|
||||
uninstall_parser = sub.add_parser("uninstall", help="Remove Cursor hooks")
|
||||
uninstall_parser.set_defaults(func=cmd_uninstall)
|
||||
|
||||
start_parser = sub.add_parser("start", help="Start the daemon")
|
||||
start_parser.add_argument(
|
||||
"--foreground", "-f", action="store_true",
|
||||
@@ -112,11 +204,6 @@ def main() -> None:
|
||||
status_parser = sub.add_parser("status", help="Check daemon status")
|
||||
status_parser.set_defaults(func=cmd_status)
|
||||
|
||||
check_parser = sub.add_parser(
|
||||
"check", help="Verify Cursor is running and accessibility works"
|
||||
)
|
||||
check_parser.set_defaults(func=cmd_check)
|
||||
|
||||
args = parser.parse_args()
|
||||
if not hasattr(args, "func"):
|
||||
parser.print_help()
|
||||
|
||||
@@ -1,54 +1,82 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
STYLE_FIELDS = {
|
||||
"color": str,
|
||||
"width": int,
|
||||
"opacity": float,
|
||||
"duration": float,
|
||||
"pulse_speed": float,
|
||||
"sound": str,
|
||||
"volume": float,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StyleConfig:
|
||||
color: str = "#FF9500"
|
||||
width: int = 4
|
||||
opacity: float = 0.85
|
||||
duration: float = 1.5
|
||||
pulse_speed: float = 1.5
|
||||
sound: str = "Glass"
|
||||
volume: float = 0.5
|
||||
|
||||
|
||||
def _default_running() -> StyleConfig:
|
||||
return StyleConfig()
|
||||
|
||||
|
||||
def _default_completed() -> StyleConfig:
|
||||
return StyleConfig(sound="", volume=0.0)
|
||||
|
||||
|
||||
@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
|
||||
running: StyleConfig = field(default_factory=_default_running)
|
||||
completed: StyleConfig = field(default_factory=_default_completed)
|
||||
|
||||
sound_enabled: bool = True
|
||||
sound_name: str = "Glass"
|
||||
sound_volume: float = 0.5
|
||||
flash_mode: str = "screen"
|
||||
|
||||
poll_interval: float = 0.5
|
||||
cooldown: float = 3.0
|
||||
approval_tools: list[str] = field(
|
||||
default_factory=lambda: ["Shell", "Write", "Delete"]
|
||||
)
|
||||
|
||||
auto_dismiss: int = 300
|
||||
approval_delay: float = 2.5
|
||||
cooldown: float = 2.0
|
||||
|
||||
|
||||
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",
|
||||
},
|
||||
GENERAL_FIELD_MAP: dict[str, str] = {
|
||||
"approval_delay": "approval_delay",
|
||||
"cooldown": "cooldown",
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml"
|
||||
|
||||
|
||||
def _parse_style(raw_section: dict, defaults: StyleConfig) -> StyleConfig:
|
||||
"""Build a StyleConfig from a YAML section, falling back to defaults."""
|
||||
overrides: dict[str, Any] = {}
|
||||
for key, typ in STYLE_FIELDS.items():
|
||||
if key in raw_section:
|
||||
overrides[key] = typ(raw_section[key])
|
||||
|
||||
return StyleConfig(
|
||||
color=overrides.get("color", defaults.color),
|
||||
width=overrides.get("width", defaults.width),
|
||||
opacity=overrides.get("opacity", defaults.opacity),
|
||||
duration=overrides.get("duration", defaults.duration),
|
||||
pulse_speed=overrides.get("pulse_speed", defaults.pulse_speed),
|
||||
sound=overrides.get("sound", defaults.sound),
|
||||
volume=overrides.get("volume", defaults.volume),
|
||||
)
|
||||
|
||||
|
||||
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
||||
"""Load config from YAML, falling back to defaults for missing values."""
|
||||
if not path.exists():
|
||||
@@ -60,13 +88,28 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
||||
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]
|
||||
config_kwargs: dict[str, Any] = {}
|
||||
|
||||
return Config(**overrides)
|
||||
running_raw = raw.get("running")
|
||||
if isinstance(running_raw, dict):
|
||||
config_kwargs["running"] = _parse_style(running_raw, _default_running())
|
||||
|
||||
completed_raw = raw.get("completed")
|
||||
if isinstance(completed_raw, dict):
|
||||
config_kwargs["completed"] = _parse_style(completed_raw, _default_completed())
|
||||
|
||||
flash_raw = raw.get("flash")
|
||||
if isinstance(flash_raw, dict) and "mode" in flash_raw:
|
||||
config_kwargs["flash_mode"] = flash_raw["mode"]
|
||||
|
||||
general_raw = raw.get("general", {})
|
||||
if isinstance(general_raw, dict):
|
||||
for yaml_key, field_name in GENERAL_FIELD_MAP.items():
|
||||
if yaml_key in general_raw:
|
||||
config_kwargs[field_name] = general_raw[yaml_key]
|
||||
|
||||
tools = raw.get("approval_tools")
|
||||
if isinstance(tools, list):
|
||||
config_kwargs["approval_tools"] = [str(t) for t in tools]
|
||||
|
||||
return Config(**config_kwargs)
|
||||
|
||||
@@ -1,92 +1,280 @@
|
||||
"""Main daemon loop that ties detection, state machine, overlay, and sound together."""
|
||||
"""Daemon that listens for flash triggers from Cursor hooks via a Unix socket."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import time
|
||||
|
||||
from Cocoa import NSApplication, NSRunLoop, NSDate
|
||||
from Quartz import (
|
||||
CGEventSourceSecondsSinceLastEventType,
|
||||
kCGEventSourceStateHIDSystemState,
|
||||
kCGEventLeftMouseDown,
|
||||
kCGEventRightMouseDown,
|
||||
kCGEventKeyDown,
|
||||
)
|
||||
|
||||
from cursor_flasher.config import Config, load_config
|
||||
from cursor_flasher.detector import CursorDetector
|
||||
from cursor_flasher.overlay import OverlayManager
|
||||
from cursor_flasher.sound import play_alert
|
||||
from cursor_flasher.state import FlasherState, StateMachine
|
||||
from cursor_flasher.windows import (
|
||||
find_window_by_workspace,
|
||||
screen_frame_for_window,
|
||||
all_screen_frames,
|
||||
is_cursor_frontmost,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cursor_flasher")
|
||||
|
||||
SOCKET_DIR = os.path.expanduser("~/.cursor-flasher")
|
||||
SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock")
|
||||
|
||||
INPUT_DISMISS_GRACE = 0.5
|
||||
|
||||
|
||||
class _PendingApproval:
|
||||
"""An approval trigger waiting for the delay to expire before pulsing."""
|
||||
__slots__ = ("workspace", "tool", "timestamp")
|
||||
|
||||
def __init__(self, workspace: str, tool: str, timestamp: float):
|
||||
self.workspace = workspace
|
||||
self.tool = tool
|
||||
self.timestamp = timestamp
|
||||
|
||||
|
||||
class FlasherDaemon:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.state_machine = StateMachine(cooldown=config.cooldown)
|
||||
self.detector = CursorDetector()
|
||||
self.overlay = OverlayManager(config)
|
||||
self.overlay = OverlayManager()
|
||||
self._running = False
|
||||
self._waiting_since: float | None = None
|
||||
self._server: socket.socket | None = None
|
||||
self._last_flash: float = 0
|
||||
self._pending: _PendingApproval | None = None
|
||||
self._pulse_started_at: float = 0
|
||||
self._cursor_was_frontmost: bool = False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the main loop. Blocks until stopped."""
|
||||
NSApplication.sharedApplication()
|
||||
self._running = True
|
||||
self._setup_socket()
|
||||
|
||||
signal.signal(signal.SIGTERM, self._handle_signal)
|
||||
signal.signal(signal.SIGINT, self._handle_signal)
|
||||
|
||||
logger.info("Cursor Flasher daemon started")
|
||||
logger.info("Cursor Flasher daemon started (socket: %s)", SOCKET_PATH)
|
||||
logger.info(
|
||||
"Approval tools: %s delay: %.1fs",
|
||||
self.config.approval_tools,
|
||||
self.config.approval_delay,
|
||||
)
|
||||
|
||||
while self._running:
|
||||
self._tick()
|
||||
self._check_socket()
|
||||
self._check_pending()
|
||||
self._check_input_dismiss()
|
||||
self._check_focus()
|
||||
NSRunLoop.currentRunLoop().runUntilDate_(
|
||||
NSDate.dateWithTimeIntervalSinceNow_(self.config.poll_interval)
|
||||
NSDate.dateWithTimeIntervalSinceNow_(0.1)
|
||||
)
|
||||
|
||||
self.overlay.hide()
|
||||
logger.info("Cursor Flasher daemon stopped")
|
||||
self._cleanup()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def _tick(self) -> None:
|
||||
result = self.detector.poll()
|
||||
def _setup_socket(self) -> None:
|
||||
os.makedirs(SOCKET_DIR, exist_ok=True)
|
||||
if os.path.exists(SOCKET_PATH):
|
||||
os.unlink(SOCKET_PATH)
|
||||
|
||||
if result is None:
|
||||
if self.state_machine.state == FlasherState.WAITING_FOR_USER:
|
||||
self.state_machine.dismiss()
|
||||
self.overlay.hide()
|
||||
self._waiting_since = None
|
||||
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._server.bind(SOCKET_PATH)
|
||||
self._server.listen(5)
|
||||
self._server.setblocking(False)
|
||||
|
||||
def _check_socket(self) -> None:
|
||||
if self._server is None:
|
||||
return
|
||||
try:
|
||||
conn, _ = self._server.accept()
|
||||
try:
|
||||
data = conn.recv(4096)
|
||||
if data:
|
||||
self._handle_message(data)
|
||||
finally:
|
||||
conn.close()
|
||||
except BlockingIOError:
|
||||
pass
|
||||
|
||||
def _check_pending(self) -> None:
|
||||
"""Promote a pending approval to an active pulse after the delay expires."""
|
||||
if self._pending is None:
|
||||
return
|
||||
|
||||
changed = self.state_machine.update(
|
||||
agent_working=result.signals.agent_working,
|
||||
approval_needed=result.signals.approval_needed,
|
||||
elapsed = time.monotonic() - self._pending.timestamp
|
||||
if elapsed < self.config.approval_delay:
|
||||
return
|
||||
|
||||
pending = self._pending
|
||||
self._pending = None
|
||||
|
||||
window = find_window_by_workspace(pending.workspace)
|
||||
if window is None:
|
||||
logger.warning("No Cursor window found for pending approval")
|
||||
return
|
||||
|
||||
frames = self._resolve_frames(window["frame"])
|
||||
logger.info(
|
||||
"Pulsing for approval (after %.1fs delay): tool=%s window=%s",
|
||||
elapsed, pending.tool, window["title"],
|
||||
)
|
||||
self.overlay.pulse(frames, self.config.running)
|
||||
self._pulse_started_at = time.monotonic()
|
||||
self._cursor_was_frontmost = is_cursor_frontmost()
|
||||
play_alert(self.config.running)
|
||||
self._last_flash = time.monotonic()
|
||||
|
||||
def _check_input_dismiss(self) -> None:
|
||||
"""Dismiss pulse when user clicks or types while Cursor is frontmost.
|
||||
|
||||
Polls CGEventSourceSecondsSinceLastEventType which reads system-wide
|
||||
HID counters — works reliably from forked daemon processes unlike
|
||||
CGEventTap callbacks which silently fail without a window server
|
||||
connection.
|
||||
"""
|
||||
if not self.overlay.is_pulsing:
|
||||
return
|
||||
if not is_cursor_frontmost():
|
||||
return
|
||||
|
||||
pulse_age = time.monotonic() - self._pulse_started_at
|
||||
if pulse_age < INPUT_DISMISS_GRACE:
|
||||
return
|
||||
|
||||
last_click = CGEventSourceSecondsSinceLastEventType(
|
||||
kCGEventSourceStateHIDSystemState, kCGEventLeftMouseDown
|
||||
)
|
||||
last_rclick = CGEventSourceSecondsSinceLastEventType(
|
||||
kCGEventSourceStateHIDSystemState, kCGEventRightMouseDown
|
||||
)
|
||||
last_key = CGEventSourceSecondsSinceLastEventType(
|
||||
kCGEventSourceStateHIDSystemState, kCGEventKeyDown
|
||||
)
|
||||
|
||||
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:
|
||||
logger.info("Auto-dismissing after timeout")
|
||||
self.state_machine.dismiss()
|
||||
self.overlay.hide()
|
||||
self._waiting_since = None
|
||||
last_input = min(last_click, last_rclick, last_key)
|
||||
|
||||
if last_input < (pulse_age - INPUT_DISMISS_GRACE):
|
||||
logger.info(
|
||||
"User input in Cursor — dismissing pulse "
|
||||
"(input %.1fs ago, pulse %.1fs old)",
|
||||
last_input, pulse_age,
|
||||
)
|
||||
self._dismiss_pulse()
|
||||
|
||||
def _check_focus(self) -> None:
|
||||
"""Dismiss pulse when user switches TO Cursor via Cmd+Tab or similar.
|
||||
|
||||
Detects the transition from another app to Cursor.
|
||||
"""
|
||||
if not self.overlay.is_pulsing:
|
||||
return
|
||||
|
||||
logger.info("State → %s", self.state_machine.state.value)
|
||||
frontmost = is_cursor_frontmost()
|
||||
if frontmost and not self._cursor_was_frontmost:
|
||||
logger.info("Cursor became frontmost — dismissing pulse")
|
||||
self._dismiss_pulse()
|
||||
self._cursor_was_frontmost = frontmost
|
||||
|
||||
match self.state_machine.state:
|
||||
case FlasherState.WAITING_FOR_USER:
|
||||
if result.active_windows:
|
||||
logger.info(
|
||||
"Showing overlay on %d window(s)", len(result.active_windows)
|
||||
)
|
||||
self.overlay.show(result.active_windows)
|
||||
play_alert(self.config)
|
||||
self._waiting_since = time.monotonic()
|
||||
case FlasherState.AGENT_WORKING | FlasherState.IDLE:
|
||||
self.overlay.hide()
|
||||
self._waiting_since = None
|
||||
def _dismiss_pulse(self) -> None:
|
||||
"""Centralized pulse dismissal."""
|
||||
self.overlay.dismiss()
|
||||
|
||||
def _handle_message(self, raw: bytes) -> None:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
logger.warning("Invalid message received")
|
||||
return
|
||||
|
||||
workspace = msg.get("workspace", "")
|
||||
event = msg.get("event", "")
|
||||
tool = msg.get("tool", "")
|
||||
|
||||
logger.info("Received: event=%s tool=%s pulsing=%s pending=%s",
|
||||
event, tool, self.overlay.is_pulsing,
|
||||
self._pending is not None)
|
||||
|
||||
if event == "preToolUse":
|
||||
self._handle_approval(workspace, tool)
|
||||
elif event in ("postToolUse", "postToolUseFailure"):
|
||||
self._handle_dismiss(event, tool)
|
||||
elif event == "stop":
|
||||
self._handle_stop(workspace)
|
||||
else:
|
||||
logger.debug("Ignoring event: %s", event)
|
||||
|
||||
def _handle_approval(self, workspace: str, tool: str) -> None:
|
||||
if tool and tool not in self.config.approval_tools:
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
if (now - self._last_flash) < self.config.cooldown:
|
||||
return
|
||||
|
||||
logger.debug("Queuing approval: tool=%s workspace=%s", tool, workspace)
|
||||
self._pending = _PendingApproval(workspace, tool, now)
|
||||
|
||||
def _handle_dismiss(self, event: str, tool: str) -> None:
|
||||
if self._pending is not None:
|
||||
logger.debug(
|
||||
"Cancelled pending approval (auto-approved): %s tool=%s",
|
||||
event, tool,
|
||||
)
|
||||
self._pending = None
|
||||
|
||||
if self.overlay.is_pulsing:
|
||||
logger.info("Dismissing pulse: %s tool=%s", event, tool)
|
||||
self._dismiss_pulse()
|
||||
|
||||
def _handle_stop(self, workspace: str) -> None:
|
||||
self._pending = None
|
||||
|
||||
if self.overlay.is_pulsing:
|
||||
self._dismiss_pulse()
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
if (now - self._last_flash) < self.config.cooldown:
|
||||
return
|
||||
|
||||
window = find_window_by_workspace(workspace)
|
||||
if window is None:
|
||||
return
|
||||
|
||||
frames = self._resolve_frames(window["frame"])
|
||||
logger.info("Flash for stop: window=%s", window["title"])
|
||||
self.overlay.flash(frames, self.config.completed)
|
||||
play_alert(self.config.completed)
|
||||
self._last_flash = now
|
||||
|
||||
def _resolve_frames(self, window_frame: tuple) -> list[tuple]:
|
||||
"""Return frame(s) based on flash_mode config."""
|
||||
mode = self.config.flash_mode
|
||||
if mode == "allscreens":
|
||||
return all_screen_frames()
|
||||
if mode == "screen":
|
||||
return [screen_frame_for_window(window_frame)]
|
||||
return [window_frame]
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
self.overlay.hide()
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
self._server = None
|
||||
if os.path.exists(SOCKET_PATH):
|
||||
os.unlink(SOCKET_PATH)
|
||||
logger.info("Cursor Flasher daemon stopped")
|
||||
|
||||
def _handle_signal(self, signum, frame):
|
||||
if not self._running:
|
||||
@@ -96,7 +284,6 @@ class FlasherDaemon:
|
||||
|
||||
|
||||
def run_daemon() -> None:
|
||||
"""Entry point for the daemon."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
"""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.
|
||||
- Detection is per-window: each AXWindow subtree is scanned independently
|
||||
so only the windows actually needing attention get flashed.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ApplicationServices import (
|
||||
AXUIElementCreateApplication,
|
||||
AXUIElementCopyAttributeNames,
|
||||
AXUIElementCopyAttributeValue,
|
||||
AXValueGetValue,
|
||||
kAXValueTypeCGPoint,
|
||||
kAXValueTypeCGSize,
|
||||
)
|
||||
from Cocoa import NSScreen, NSWorkspace
|
||||
|
||||
logger = logging.getLogger("cursor_flasher")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollResult:
|
||||
"""Result of polling Cursor's a11y tree."""
|
||||
signals: UISignals
|
||||
active_windows: list = field(default_factory=list)
|
||||
|
||||
|
||||
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) -> PollResult | None:
|
||||
"""Poll Cursor's a11y tree per-window.
|
||||
|
||||
Returns aggregate signals for the state machine and a list of
|
||||
AXWindow element refs for windows that need user attention.
|
||||
Returns 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)
|
||||
|
||||
err, children = AXUIElementCopyAttributeValue(
|
||||
app_element, "AXChildren", None
|
||||
)
|
||||
if err or not children:
|
||||
if err:
|
||||
logger.warning(
|
||||
"Cannot read Cursor's accessibility tree (AX error %d). "
|
||||
"Grant Accessibility permission to your terminal app: "
|
||||
"System Settings → Privacy & Security → Accessibility",
|
||||
err,
|
||||
)
|
||||
return PollResult(signals=UISignals())
|
||||
|
||||
aggregate_working = False
|
||||
aggregate_approval = False
|
||||
active_windows: list = []
|
||||
|
||||
for child in children:
|
||||
err, role = AXUIElementCopyAttributeValue(child, "AXRole", None)
|
||||
if err or str(role) != "AXWindow":
|
||||
continue
|
||||
|
||||
elements = self._collect_elements(child, max_depth=15)
|
||||
signals = parse_ui_signals(elements)
|
||||
|
||||
if signals.agent_working:
|
||||
aggregate_working = True
|
||||
if signals.approval_needed:
|
||||
aggregate_approval = True
|
||||
active_windows.append(child)
|
||||
|
||||
return PollResult(
|
||||
signals=UISignals(
|
||||
agent_working=aggregate_working,
|
||||
approval_needed=aggregate_approval,
|
||||
),
|
||||
active_windows=active_windows,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def check_accessibility() -> dict:
|
||||
"""Run a diagnostic check and return a status dict.
|
||||
|
||||
Returns keys: cursor_running, cursor_pid, ax_accessible, window_count, error.
|
||||
"""
|
||||
result: dict = {
|
||||
"cursor_running": False,
|
||||
"cursor_pid": None,
|
||||
"ax_accessible": False,
|
||||
"window_count": 0,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
workspace = NSWorkspace.sharedWorkspace()
|
||||
for app in workspace.runningApplications():
|
||||
bundle = app.bundleIdentifier() or ""
|
||||
if bundle == CURSOR_BUNDLE_ID:
|
||||
result["cursor_running"] = True
|
||||
result["cursor_pid"] = app.processIdentifier()
|
||||
break
|
||||
|
||||
if not result["cursor_running"]:
|
||||
result["error"] = "Cursor is not running"
|
||||
return result
|
||||
|
||||
app_element = AXUIElementCreateApplication(result["cursor_pid"])
|
||||
err, children = AXUIElementCopyAttributeValue(
|
||||
app_element, "AXChildren", None
|
||||
)
|
||||
|
||||
if err:
|
||||
AX_ERRORS = {
|
||||
-25200: "Not a valid AXUIElement",
|
||||
-25201: "Accessibility not enabled / permission denied",
|
||||
-25202: "Action not supported",
|
||||
-25203: "Notification not supported",
|
||||
-25204: "Not implemented",
|
||||
-25205: "Notification already registered",
|
||||
-25206: "Notification not registered",
|
||||
-25207: "API disabled",
|
||||
-25208: "Invalid UIElement observer",
|
||||
-25209: "Cannot complete action",
|
||||
-25210: "Attribute not settable",
|
||||
-25211: "Attribute not supported",
|
||||
-25212: "Parameterized attribute not supported",
|
||||
-25213: "Not enough precision",
|
||||
}
|
||||
desc = AX_ERRORS.get(err, "unknown")
|
||||
result["error"] = (
|
||||
f"Cannot read accessibility tree (error {err}: {desc}). "
|
||||
"Your terminal app needs Accessibility permission: "
|
||||
"System Settings → Privacy & Security → Accessibility"
|
||||
)
|
||||
return result
|
||||
|
||||
if not children:
|
||||
result["error"] = "Accessibility tree is empty (Cursor may be loading)"
|
||||
return result
|
||||
|
||||
result["ax_accessible"] = True
|
||||
for child in children:
|
||||
e2, role = AXUIElementCopyAttributeValue(child, "AXRole", None)
|
||||
if not e2 and str(role) == "AXWindow":
|
||||
result["window_count"] += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_ax_window_frame(ax_window) -> tuple | None:
|
||||
"""Extract an AXWindow's screen frame as an NS-coordinate tuple.
|
||||
|
||||
Returns ((x, y), (w, h)) in AppKit coordinates (bottom-left origin),
|
||||
or None if the attributes can't be read.
|
||||
"""
|
||||
_, pos_val = AXUIElementCopyAttributeValue(ax_window, "AXPosition", None)
|
||||
_, size_val = AXUIElementCopyAttributeValue(ax_window, "AXSize", None)
|
||||
if pos_val is None or size_val is None:
|
||||
return None
|
||||
|
||||
_, point = AXValueGetValue(pos_val, kAXValueTypeCGPoint, None)
|
||||
_, size = AXValueGetValue(size_val, kAXValueTypeCGSize, None)
|
||||
if point is None or size is None:
|
||||
return None
|
||||
|
||||
screen_height = NSScreen.mainScreen().frame().size.height
|
||||
x = point.x
|
||||
w = size.width
|
||||
h = size.height
|
||||
y = screen_height - point.y - h
|
||||
|
||||
return ((x, y), (w, h))
|
||||
@@ -1,25 +1,25 @@
|
||||
"""Native macOS overlay window that draws a pulsing border around target windows."""
|
||||
"""Native macOS overlay that draws a flash or pulsing border around one or more frames."""
|
||||
import enum
|
||||
import logging
|
||||
import math
|
||||
|
||||
import objc
|
||||
from Cocoa import (
|
||||
NSApplication,
|
||||
NSWindow,
|
||||
NSBorderlessWindowMask,
|
||||
NSColor,
|
||||
NSView,
|
||||
NSBezierPath,
|
||||
NSTimer,
|
||||
NSScreen,
|
||||
)
|
||||
from Foundation import NSInsetRect
|
||||
|
||||
from cursor_flasher.config import Config
|
||||
from cursor_flasher.detector import get_ax_window_frame
|
||||
from cursor_flasher.config import StyleConfig
|
||||
|
||||
logger = logging.getLogger("cursor_flasher")
|
||||
|
||||
|
||||
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
|
||||
@@ -27,157 +27,176 @@ def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
|
||||
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
|
||||
|
||||
|
||||
class PulseBorderView(NSView):
|
||||
"""Custom view that draws a pulsing border rectangle."""
|
||||
class FlashBorderView(NSView):
|
||||
"""View that draws a solid border rectangle at a given opacity."""
|
||||
|
||||
def initWithFrame_config_(self, frame, config):
|
||||
self = objc.super(PulseBorderView, self).initWithFrame_(frame)
|
||||
def initWithFrame_(self, frame):
|
||||
self = objc.super(FlashBorderView, self).initWithFrame_(frame)
|
||||
if self is None:
|
||||
return None
|
||||
self._config = config
|
||||
self._phase = 0.0
|
||||
self._style = StyleConfig()
|
||||
self._alpha = 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)
|
||||
if self._alpha <= 0:
|
||||
return
|
||||
color = hex_to_nscolor(self._style.color, self._alpha)
|
||||
color.setStroke()
|
||||
|
||||
width = self._config.pulse_width
|
||||
width = self._style.width
|
||||
inset = width / 2.0
|
||||
bounds = objc.super(PulseBorderView, self).bounds()
|
||||
bounds = objc.super(FlashBorderView, self).bounds()
|
||||
inset_rect = NSInsetRect(bounds, inset, inset)
|
||||
path = NSBezierPath.bezierPathWithRect_(inset_rect)
|
||||
path.setLineWidth_(width)
|
||||
path.stroke()
|
||||
|
||||
def setPhase_(self, phase):
|
||||
self._phase = phase
|
||||
def setAlpha_(self, alpha):
|
||||
self._alpha = alpha
|
||||
self.setNeedsDisplay_(True)
|
||||
|
||||
|
||||
class _OverlayEntry:
|
||||
"""A single overlay window + view pair tracking an AXWindow."""
|
||||
__slots__ = ("window", "view")
|
||||
|
||||
def __init__(self, window: NSWindow, view: PulseBorderView):
|
||||
self.window = window
|
||||
self.view = view
|
||||
class _Mode(enum.Enum):
|
||||
IDLE = "idle"
|
||||
FLASH = "flash"
|
||||
PULSE = "pulse"
|
||||
|
||||
|
||||
class OverlayManager:
|
||||
"""Manages overlay windows for Cursor windows that need attention.
|
||||
"""Manages overlay borders on one or more frames simultaneously.
|
||||
|
||||
Accepts AXWindow element refs from the detector and creates one
|
||||
overlay per window, reading position directly from the a11y element.
|
||||
Two modes:
|
||||
- flash(): brief fade-in/hold/fade-out, auto-dismisses
|
||||
- pulse(): continuous sine-wave pulsing until dismiss() is called
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
self._overlays: list[_OverlayEntry] = []
|
||||
self._ax_windows: list = []
|
||||
def __init__(self):
|
||||
self._panels: list[tuple[NSWindow, FlashBorderView]] = []
|
||||
self._timer: NSTimer | None = None
|
||||
self._phase = 0.0
|
||||
self._elapsed: float = 0.0
|
||||
self._mode: _Mode = _Mode.IDLE
|
||||
self._style: StyleConfig = StyleConfig()
|
||||
|
||||
def show(self, ax_windows: list) -> None:
|
||||
"""Show pulsing overlays around the given AXWindow elements."""
|
||||
self._ax_windows = list(ax_windows)
|
||||
frames = self._read_frames()
|
||||
if not frames:
|
||||
return
|
||||
@property
|
||||
def is_pulsing(self) -> bool:
|
||||
return self._mode == _Mode.PULSE
|
||||
|
||||
self._sync_overlays(frames)
|
||||
def flash(self, frames: list[tuple], style: StyleConfig) -> None:
|
||||
"""Brief flash: fade in, hold, fade out, auto-dismiss."""
|
||||
self._show(frames, _Mode.FLASH, style)
|
||||
|
||||
for entry in self._overlays:
|
||||
entry.window.orderFrontRegardless()
|
||||
def pulse(self, frames: list[tuple], style: StyleConfig) -> None:
|
||||
"""Continuous pulse: sine-wave opacity until dismiss() is called."""
|
||||
self._show(frames, _Mode.PULSE, style)
|
||||
|
||||
self._start_animation()
|
||||
def dismiss(self) -> None:
|
||||
"""Stop any animation and hide all overlays."""
|
||||
self._stop_timer()
|
||||
self._mode = _Mode.IDLE
|
||||
for window, view in self._panels:
|
||||
view.setAlpha_(0.0)
|
||||
window.setAlphaValue_(0.0)
|
||||
window.orderOut_(None)
|
||||
logger.debug("Overlay dismissed (%d panels hidden)", len(self._panels))
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide all overlay windows."""
|
||||
self._stop_animation()
|
||||
for entry in self._overlays:
|
||||
entry.window.orderOut_(None)
|
||||
self._ax_windows = []
|
||||
self.dismiss()
|
||||
|
||||
def _read_frames(self) -> list[tuple]:
|
||||
"""Read current frames from stored AXWindow refs."""
|
||||
frames = []
|
||||
for ax_win in self._ax_windows:
|
||||
frame = get_ax_window_frame(ax_win)
|
||||
if frame is not None:
|
||||
frames.append(frame)
|
||||
return frames
|
||||
def _show(self, frames: list[tuple], mode: _Mode, style: StyleConfig) -> None:
|
||||
self._stop_timer()
|
||||
self._elapsed = 0.0
|
||||
self._mode = mode
|
||||
self._style = style
|
||||
|
||||
def _sync_overlays(self, frames: list[tuple]) -> None:
|
||||
"""Ensure we have exactly len(frames) overlays, positioned correctly."""
|
||||
needed = len(frames)
|
||||
existing = len(self._overlays)
|
||||
self._ensure_panels(len(frames))
|
||||
|
||||
for i in range(needed):
|
||||
frame = frames[i]
|
||||
for i, frame in enumerate(frames):
|
||||
window, view = self._panels[i]
|
||||
view._style = style
|
||||
window.setFrame_display_(frame, True)
|
||||
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
||||
view.setFrame_(content_rect)
|
||||
view.setAlpha_(style.opacity)
|
||||
window.setAlphaValue_(1.0)
|
||||
window.orderFrontRegardless()
|
||||
|
||||
if i < existing:
|
||||
entry = self._overlays[i]
|
||||
entry.window.setFrame_display_(frame, True)
|
||||
entry.view.setFrame_(content_rect)
|
||||
else:
|
||||
entry = self._create_overlay(frame)
|
||||
self._overlays.append(entry)
|
||||
entry.window.orderFrontRegardless()
|
||||
for j in range(len(frames), len(self._panels)):
|
||||
self._panels[j][0].orderOut_(None)
|
||||
|
||||
for i in range(needed, existing):
|
||||
self._overlays[i].window.orderOut_(None)
|
||||
|
||||
if needed < existing:
|
||||
self._overlays = self._overlays[:needed]
|
||||
|
||||
def _create_overlay(self, frame) -> _OverlayEntry:
|
||||
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
|
||||
)
|
||||
window.setOpaque_(False)
|
||||
window.setBackgroundColor_(NSColor.clearColor())
|
||||
window.setLevel_(25) # Above normal windows
|
||||
window.setIgnoresMouseEvents_(True)
|
||||
window.setHasShadow_(False)
|
||||
|
||||
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
||||
view = PulseBorderView.alloc().initWithFrame_config_(
|
||||
content_rect, self._config
|
||||
)
|
||||
window.setContentView_(view)
|
||||
return _OverlayEntry(window, view)
|
||||
|
||||
def _start_animation(self) -> None:
|
||||
if self._timer is not None:
|
||||
return
|
||||
interval = 1.0 / 30.0 # 30 fps
|
||||
interval = 1.0 / 30.0
|
||||
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
interval, self, "_tick:", None, True
|
||||
)
|
||||
|
||||
def _stop_animation(self) -> None:
|
||||
def _ensure_panels(self, count: int) -> None:
|
||||
"""Grow the panel pool if needed."""
|
||||
while len(self._panels) < count:
|
||||
dummy = ((0, 0), (1, 1))
|
||||
self._panels.append(self._create_overlay(dummy))
|
||||
|
||||
def _create_overlay(self, frame) -> tuple:
|
||||
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||
frame, NSBorderlessWindowMask, 2, False
|
||||
)
|
||||
window.setOpaque_(False)
|
||||
window.setBackgroundColor_(NSColor.clearColor())
|
||||
window.setLevel_(2147483631)
|
||||
window.setIgnoresMouseEvents_(True)
|
||||
window.setHasShadow_(False)
|
||||
|
||||
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
||||
view = FlashBorderView.alloc().initWithFrame_(content_rect)
|
||||
window.setContentView_(view)
|
||||
return window, view
|
||||
|
||||
def _stop_timer(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
|
||||
for entry in self._overlays:
|
||||
entry.view.setPhase_(self._phase)
|
||||
frames = self._read_frames()
|
||||
if frames:
|
||||
self._sync_overlays(frames)
|
||||
dt = 1.0 / 30.0
|
||||
self._elapsed += dt
|
||||
|
||||
match self._mode:
|
||||
case _Mode.FLASH:
|
||||
self._tick_flash()
|
||||
case _Mode.PULSE:
|
||||
self._tick_pulse()
|
||||
case _Mode.IDLE:
|
||||
self._stop_timer()
|
||||
|
||||
def _tick_flash(self) -> None:
|
||||
duration = self._style.duration
|
||||
fade_in = 0.15
|
||||
fade_out = 0.4
|
||||
hold_end = duration - fade_out
|
||||
|
||||
if self._elapsed < fade_in:
|
||||
alpha = self._style.opacity * (self._elapsed / fade_in)
|
||||
elif self._elapsed < hold_end:
|
||||
alpha = self._style.opacity
|
||||
elif self._elapsed < duration:
|
||||
progress = (self._elapsed - hold_end) / fade_out
|
||||
alpha = self._style.opacity * (1.0 - progress)
|
||||
else:
|
||||
self.dismiss()
|
||||
return
|
||||
|
||||
self._set_all_alpha(alpha)
|
||||
|
||||
def _tick_pulse(self) -> None:
|
||||
speed = self._style.pulse_speed
|
||||
phase = (2.0 * math.pi * self._elapsed) / speed
|
||||
opacity_min = 0.3
|
||||
t = 0.5 + 0.5 * math.sin(phase)
|
||||
alpha = opacity_min + (self._style.opacity - opacity_min) * t
|
||||
self._set_all_alpha(alpha)
|
||||
|
||||
def _set_all_alpha(self, alpha: float) -> None:
|
||||
for _, view in self._panels:
|
||||
view.setAlpha_(alpha)
|
||||
|
||||
def _tick_(self, timer) -> None:
|
||||
self._tick_impl()
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"""System sound playback."""
|
||||
from Cocoa import NSSound
|
||||
|
||||
from cursor_flasher.config import Config
|
||||
from cursor_flasher.config import StyleConfig
|
||||
|
||||
|
||||
def play_alert(config: Config) -> None:
|
||||
"""Play the configured alert sound if enabled."""
|
||||
if not config.sound_enabled:
|
||||
def play_alert(style: StyleConfig) -> None:
|
||||
if not style.sound:
|
||||
return
|
||||
|
||||
sound = NSSound.soundNamed_(config.sound_name)
|
||||
sound = NSSound.soundNamed_(style.sound)
|
||||
if sound is None:
|
||||
return
|
||||
|
||||
sound.setVolume_(config.sound_volume)
|
||||
sound.setVolume_(style.volume)
|
||||
sound.play()
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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.
|
||||
|
||||
Only transitions to WAITING_FOR_USER after seeing AGENT_WORKING first.
|
||||
This prevents stale approval buttons in chat history from triggering
|
||||
false positives.
|
||||
"""
|
||||
old = self.state
|
||||
|
||||
match self.state:
|
||||
case FlasherState.IDLE:
|
||||
if agent_working:
|
||||
self.state = FlasherState.AGENT_WORKING
|
||||
case FlasherState.AGENT_WORKING:
|
||||
if approval_needed and not agent_working:
|
||||
if not self._in_cooldown():
|
||||
self.state = FlasherState.WAITING_FOR_USER
|
||||
else:
|
||||
self.state = FlasherState.IDLE
|
||||
elif 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
|
||||
153
src/cursor_flasher/windows.py
Normal file
153
src/cursor_flasher/windows.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Cursor window discovery and geometry via macOS accessibility APIs.
|
||||
|
||||
Only used for finding Cursor windows and reading their screen position.
|
||||
Detection of agent state is handled by Cursor hooks, not a11y polling.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from ApplicationServices import (
|
||||
AXUIElementCreateApplication,
|
||||
AXUIElementCopyAttributeValue,
|
||||
AXValueGetValue,
|
||||
kAXValueTypeCGPoint,
|
||||
kAXValueTypeCGSize,
|
||||
)
|
||||
from Cocoa import NSScreen, NSWorkspace
|
||||
|
||||
logger = logging.getLogger("cursor_flasher")
|
||||
|
||||
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
|
||||
|
||||
|
||||
def is_cursor_frontmost() -> bool:
|
||||
"""Return True if Cursor is the frontmost (active) application."""
|
||||
app = NSWorkspace.sharedWorkspace().frontmostApplication()
|
||||
if app is None:
|
||||
return False
|
||||
return (app.bundleIdentifier() or "") == CURSOR_BUNDLE_ID
|
||||
|
||||
|
||||
def find_cursor_pid() -> 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 get_cursor_windows() -> list[dict]:
|
||||
"""Return a list of Cursor windows with their titles and AX-coordinate frames.
|
||||
|
||||
Each entry: {"title": str, "frame": ((x, y), (w, h))}
|
||||
Frame is in AppKit coordinates (bottom-left origin).
|
||||
"""
|
||||
pid = find_cursor_pid()
|
||||
if pid is None:
|
||||
return []
|
||||
|
||||
app = AXUIElementCreateApplication(pid)
|
||||
err, children = AXUIElementCopyAttributeValue(app, "AXChildren", None)
|
||||
if err or not children:
|
||||
return []
|
||||
|
||||
screen_height = NSScreen.mainScreen().frame().size.height
|
||||
results = []
|
||||
|
||||
for child in children:
|
||||
err, role = AXUIElementCopyAttributeValue(child, "AXRole", None)
|
||||
if err or str(role) != "AXWindow":
|
||||
continue
|
||||
|
||||
err, title = AXUIElementCopyAttributeValue(child, "AXTitle", None)
|
||||
title = str(title) if not err and title else ""
|
||||
|
||||
frame = _read_frame(child, screen_height)
|
||||
if frame is None:
|
||||
continue
|
||||
|
||||
results.append({"title": title, "frame": frame})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def find_window_by_workspace(workspace_path: str) -> dict | None:
|
||||
"""Find the Cursor window whose title contains the workspace folder name.
|
||||
|
||||
Cursor titles are typically "<filename> — <project-name>".
|
||||
Returns None if no match or ambiguous (avoids flashing the wrong window).
|
||||
Only falls back to the sole window if there's exactly one.
|
||||
"""
|
||||
windows = get_cursor_windows()
|
||||
|
||||
if not windows:
|
||||
return None
|
||||
|
||||
if not workspace_path:
|
||||
return windows[0] if len(windows) == 1 else None
|
||||
|
||||
folder_name = workspace_path.rstrip("/").rsplit("/", 1)[-1]
|
||||
if not folder_name:
|
||||
return windows[0] if len(windows) == 1 else None
|
||||
|
||||
matches = [w for w in windows if folder_name in w["title"]]
|
||||
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
|
||||
if len(matches) > 1:
|
||||
exact = [w for w in matches if w["title"].endswith(folder_name)]
|
||||
if len(exact) == 1:
|
||||
return exact[0]
|
||||
return matches[0]
|
||||
|
||||
return windows[0] if len(windows) == 1 else None
|
||||
|
||||
|
||||
def screen_frame_for_window(window_frame: tuple) -> tuple:
|
||||
"""Return the NSScreen frame of the monitor containing the window's center.
|
||||
|
||||
Falls back to the main screen if no screen contains the center point.
|
||||
"""
|
||||
wx, wy = window_frame[0]
|
||||
ww, wh = window_frame[1]
|
||||
cx = wx + ww / 2.0
|
||||
cy = wy + wh / 2.0
|
||||
|
||||
for screen in NSScreen.screens():
|
||||
sf = screen.frame()
|
||||
sx, sy = sf.origin.x, sf.origin.y
|
||||
sw, sh = sf.size.width, sf.size.height
|
||||
if sx <= cx < sx + sw and sy <= cy < sy + sh:
|
||||
return ((sx, sy), (sw, sh))
|
||||
|
||||
main = NSScreen.mainScreen().frame()
|
||||
return ((main.origin.x, main.origin.y), (main.size.width, main.size.height))
|
||||
|
||||
|
||||
def all_screen_frames() -> list[tuple]:
|
||||
"""Return frames for every connected screen."""
|
||||
frames = []
|
||||
for screen in NSScreen.screens():
|
||||
sf = screen.frame()
|
||||
frames.append(((sf.origin.x, sf.origin.y), (sf.size.width, sf.size.height)))
|
||||
return frames
|
||||
|
||||
|
||||
def _read_frame(ax_window, screen_height: float) -> tuple | None:
|
||||
_, pos_val = AXUIElementCopyAttributeValue(ax_window, "AXPosition", None)
|
||||
_, size_val = AXUIElementCopyAttributeValue(ax_window, "AXSize", None)
|
||||
if pos_val is None or size_val is None:
|
||||
return None
|
||||
|
||||
_, point = AXValueGetValue(pos_val, kAXValueTypeCGPoint, None)
|
||||
_, size = AXValueGetValue(size_val, kAXValueTypeCGSize, None)
|
||||
if point is None or size is None:
|
||||
return None
|
||||
|
||||
x = point.x
|
||||
w = size.width
|
||||
h = size.height
|
||||
y = screen_height - point.y - h
|
||||
|
||||
return ((x, y), (w, h))
|
||||
@@ -1,56 +1,119 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from cursor_flasher.config import Config, load_config, DEFAULT_CONFIG_PATH
|
||||
from cursor_flasher.config import Config, StyleConfig, load_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_running_defaults(self):
|
||||
c = Config()
|
||||
assert c.running.color == "#FF9500"
|
||||
assert c.running.width == 4
|
||||
assert c.running.duration == 1.5
|
||||
assert c.running.opacity == 0.85
|
||||
assert c.running.pulse_speed == 1.5
|
||||
assert c.running.sound == "Glass"
|
||||
assert c.running.volume == 0.5
|
||||
|
||||
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_completed_defaults(self):
|
||||
c = Config()
|
||||
assert c.completed.color == "#FF9500"
|
||||
assert c.completed.width == 4
|
||||
assert c.completed.sound == ""
|
||||
assert c.completed.volume == 0.0
|
||||
|
||||
def test_has_detection_settings(self):
|
||||
cfg = Config()
|
||||
assert cfg.poll_interval == 0.5
|
||||
assert cfg.cooldown == 3.0
|
||||
def test_has_approval_tools(self):
|
||||
c = Config()
|
||||
assert c.approval_tools == ["Shell", "Write", "Delete"]
|
||||
|
||||
def test_has_timeout_settings(self):
|
||||
cfg = Config()
|
||||
assert cfg.auto_dismiss == 300
|
||||
def test_has_cooldown(self):
|
||||
c = Config()
|
||||
assert c.cooldown == 2.0
|
||||
|
||||
def test_has_flash_mode(self):
|
||||
c = Config()
|
||||
assert c.flash_mode == "screen"
|
||||
|
||||
|
||||
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
|
||||
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"
|
||||
c = load_config(tmp_path / "nope.yaml")
|
||||
assert c == Config()
|
||||
|
||||
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"
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("")
|
||||
c = load_config(p)
|
||||
assert c == Config()
|
||||
|
||||
def test_loads_running_overrides(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"running:\n color: '#00FF00'\n duration: 2.0\n sound: Ping\n"
|
||||
)
|
||||
c = load_config(p)
|
||||
assert c.running.color == "#00FF00"
|
||||
assert c.running.duration == 2.0
|
||||
assert c.running.sound == "Ping"
|
||||
assert c.running.width == 4
|
||||
|
||||
def test_loads_completed_overrides(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"completed:\n color: '#0000FF'\n sound: Hero\n volume: 0.8\n"
|
||||
)
|
||||
c = load_config(p)
|
||||
assert c.completed.color == "#0000FF"
|
||||
assert c.completed.sound == "Hero"
|
||||
assert c.completed.volume == 0.8
|
||||
|
||||
def test_loads_flash_mode(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("flash:\n mode: allscreens\n")
|
||||
c = load_config(p)
|
||||
assert c.flash_mode == "allscreens"
|
||||
|
||||
def test_loads_general_overrides(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("general:\n cooldown: 5.0\n approval_delay: 3.0\n")
|
||||
c = load_config(p)
|
||||
assert c.cooldown == 5.0
|
||||
assert c.approval_delay == 3.0
|
||||
|
||||
def test_loads_approval_tools(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("approval_tools:\n - Shell\n - MCP\n")
|
||||
c = load_config(p)
|
||||
assert c.approval_tools == ["Shell", "MCP"]
|
||||
|
||||
def test_full_config(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"running:\n"
|
||||
" color: '#FF0000'\n"
|
||||
" width: 6\n"
|
||||
" opacity: 0.9\n"
|
||||
" pulse_speed: 2.0\n"
|
||||
" sound: Glass\n"
|
||||
" volume: 0.8\n"
|
||||
"completed:\n"
|
||||
" color: '#00FF00'\n"
|
||||
" sound: ''\n"
|
||||
"flash:\n"
|
||||
" mode: window\n"
|
||||
"general:\n"
|
||||
" approval_delay: 1.0\n"
|
||||
" cooldown: 3.0\n"
|
||||
"approval_tools:\n"
|
||||
" - Shell\n"
|
||||
)
|
||||
c = load_config(p)
|
||||
assert c.running.color == "#FF0000"
|
||||
assert c.running.width == 6
|
||||
assert c.running.opacity == 0.9
|
||||
assert c.running.pulse_speed == 2.0
|
||||
assert c.running.sound == "Glass"
|
||||
assert c.running.volume == 0.8
|
||||
assert c.completed.color == "#00FF00"
|
||||
assert c.completed.sound == ""
|
||||
assert c.flash_mode == "window"
|
||||
assert c.approval_delay == 1.0
|
||||
assert c.cooldown == 3.0
|
||||
assert c.approval_tools == ["Shell"]
|
||||
|
||||
388
tests/test_daemon.py
Normal file
388
tests/test_daemon.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""Tests for the daemon's message handling logic."""
|
||||
import json
|
||||
import time
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from cursor_flasher.config import Config, StyleConfig
|
||||
from cursor_flasher.daemon import FlasherDaemon
|
||||
|
||||
|
||||
class TestFlasherDaemon:
|
||||
def _make_daemon(self, **config_overrides) -> FlasherDaemon:
|
||||
config = Config(**config_overrides)
|
||||
with patch("cursor_flasher.daemon.OverlayManager"):
|
||||
daemon = FlasherDaemon(config)
|
||||
daemon.overlay.is_pulsing = False
|
||||
return daemon
|
||||
|
||||
def test_preToolUse_queues_pending(self):
|
||||
daemon = self._make_daemon()
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
assert daemon._pending is not None
|
||||
assert daemon._pending.tool == "Shell"
|
||||
daemon.overlay.pulse.assert_not_called()
|
||||
|
||||
def test_pending_promotes_after_delay(self):
|
||||
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen")
|
||||
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||
screen = ((0, 0), (1920, 1080))
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.play_alert"):
|
||||
daemon._check_pending()
|
||||
|
||||
daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.running)
|
||||
|
||||
def test_postToolUse_cancels_pending(self):
|
||||
"""Auto-approved tools: postToolUse arrives before delay, cancels the pulse."""
|
||||
daemon = self._make_daemon(approval_delay=10.0)
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
assert daemon._pending is not None
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
assert daemon._pending is None
|
||||
daemon.overlay.pulse.assert_not_called()
|
||||
|
||||
def test_preToolUse_skips_non_approval_tool(self):
|
||||
daemon = self._make_daemon()
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Read"}).encode()
|
||||
)
|
||||
|
||||
assert daemon._pending is None
|
||||
|
||||
def test_preToolUse_respects_custom_tool_list(self):
|
||||
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen", approval_tools=["Shell", "MCP"])
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "MCP"}).encode()
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=((0, 0), (1920, 1080))), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.play_alert"):
|
||||
daemon._check_pending()
|
||||
|
||||
daemon.overlay.pulse.assert_called_once()
|
||||
|
||||
def test_stop_flashes_briefly(self):
|
||||
daemon = self._make_daemon(flash_mode="screen")
|
||||
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||
screen = ((0, 0), (1920, 1080))
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
||||
patch("cursor_flasher.daemon.play_alert"):
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
|
||||
daemon.overlay.flash.assert_called_once_with([screen], daemon.config.completed)
|
||||
|
||||
def test_stop_flashes_window_frame_when_window_mode(self):
|
||||
daemon = self._make_daemon(flash_mode="window")
|
||||
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.play_alert"):
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
|
||||
daemon.overlay.flash.assert_called_once_with(
|
||||
[((0, 0), (800, 600))], daemon.config.completed
|
||||
)
|
||||
|
||||
def test_allscreens_mode_uses_all_screens(self):
|
||||
daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0)
|
||||
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||
screens = [((0, 0), (1920, 1080)), ((-1920, 0), (1920, 1080))]
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.all_screen_frames", return_value=screens), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.play_alert"):
|
||||
daemon._check_pending()
|
||||
|
||||
daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.running)
|
||||
|
||||
def test_stop_dismisses_active_pulse(self):
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
|
||||
daemon.overlay.dismiss.assert_called_once()
|
||||
daemon.overlay.flash.assert_not_called()
|
||||
|
||||
def test_stop_clears_pending(self):
|
||||
daemon = self._make_daemon(approval_delay=10.0)
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
assert daemon._pending is not None
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
assert daemon._pending is None
|
||||
|
||||
def test_postToolUse_dismisses_active_pulse(self):
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
daemon.overlay.dismiss.assert_called_once()
|
||||
|
||||
def test_postToolUseFailure_dismisses_pulse(self):
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "postToolUseFailure", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
daemon.overlay.dismiss.assert_called_once()
|
||||
|
||||
def test_cooldown_prevents_rapid_triggers(self):
|
||||
daemon = self._make_daemon(cooldown=5.0)
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
daemon._last_flash = time.monotonic()
|
||||
daemon._pending = None
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
assert daemon._pending is None
|
||||
|
||||
def test_invalid_json_ignored(self):
|
||||
daemon = self._make_daemon()
|
||||
daemon._handle_message(b"not json")
|
||||
daemon.overlay.pulse.assert_not_called()
|
||||
daemon.overlay.flash.assert_not_called()
|
||||
|
||||
def test_no_window_found(self):
|
||||
daemon = self._make_daemon(approval_delay=0.0)
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/nope", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=None):
|
||||
daemon._check_pending()
|
||||
|
||||
daemon.overlay.pulse.assert_not_called()
|
||||
|
||||
def test_focus_transition_dismisses_pulse(self):
|
||||
"""Pulse dismisses when user switches TO Cursor from another app."""
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
daemon._cursor_was_frontmost = False
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||
daemon._check_focus()
|
||||
|
||||
daemon.overlay.dismiss.assert_called_once()
|
||||
|
||||
def test_focus_no_dismiss_when_cursor_already_frontmost(self):
|
||||
"""No dismiss if Cursor was already frontmost (no transition)."""
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
daemon._cursor_was_frontmost = True
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||
daemon._check_focus()
|
||||
|
||||
daemon.overlay.dismiss.assert_not_called()
|
||||
|
||||
def test_focus_tracks_state_changes(self):
|
||||
"""_cursor_was_frontmost updates each tick."""
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
daemon._cursor_was_frontmost = True
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
|
||||
daemon._check_focus()
|
||||
assert daemon._cursor_was_frontmost is False
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||
daemon._check_focus()
|
||||
daemon.overlay.dismiss.assert_called_once()
|
||||
|
||||
def test_focus_no_dismiss_when_not_pulsing(self):
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = False
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||
daemon._check_focus()
|
||||
|
||||
daemon.overlay.dismiss.assert_not_called()
|
||||
|
||||
def test_input_dismiss_when_pulsing_and_cursor_focused(self):
|
||||
"""Recent input + Cursor frontmost + past grace period = dismiss."""
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
daemon._pulse_started_at = time.monotonic() - 2.0
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.2):
|
||||
daemon._check_input_dismiss()
|
||||
|
||||
daemon.overlay.dismiss.assert_called_once()
|
||||
|
||||
def test_input_dismiss_skipped_during_grace_period(self):
|
||||
"""No dismiss if pulse just started (within grace period)."""
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
daemon._pulse_started_at = time.monotonic() - 0.1
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.05):
|
||||
daemon._check_input_dismiss()
|
||||
|
||||
daemon.overlay.dismiss.assert_not_called()
|
||||
|
||||
def test_input_dismiss_skipped_when_not_pulsing(self):
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = False
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.1):
|
||||
daemon._check_input_dismiss()
|
||||
|
||||
daemon.overlay.dismiss.assert_not_called()
|
||||
|
||||
def test_input_dismiss_skipped_when_cursor_not_frontmost(self):
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
daemon._pulse_started_at = time.monotonic() - 2.0
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
|
||||
daemon._check_input_dismiss()
|
||||
|
||||
daemon.overlay.dismiss.assert_not_called()
|
||||
|
||||
def test_input_dismiss_ignores_old_input(self):
|
||||
"""Input from before the pulse started should not trigger dismiss."""
|
||||
daemon = self._make_daemon()
|
||||
daemon.overlay.is_pulsing = True
|
||||
daemon._pulse_started_at = time.monotonic() - 2.0
|
||||
|
||||
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=5.0):
|
||||
daemon._check_input_dismiss()
|
||||
|
||||
daemon.overlay.dismiss.assert_not_called()
|
||||
|
||||
def test_running_style_sound_plays_on_approval(self):
|
||||
"""Running style with sound configured plays on approval pulse."""
|
||||
running = StyleConfig(sound="Glass", volume=0.5)
|
||||
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", running=running)
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
||||
daemon._check_pending()
|
||||
|
||||
mock_alert.assert_called_once_with(running)
|
||||
|
||||
def test_completed_style_sound_plays_on_stop(self):
|
||||
"""Completed style with sound configured plays on stop flash."""
|
||||
completed = StyleConfig(sound="Ping", volume=0.7)
|
||||
daemon = self._make_daemon(flash_mode="window", completed=completed)
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
|
||||
mock_alert.assert_called_once_with(completed)
|
||||
|
||||
def test_no_sound_when_style_sound_empty(self):
|
||||
"""No sound plays when the style has sound="" (the completed default)."""
|
||||
completed = StyleConfig(sound="", volume=0.0)
|
||||
daemon = self._make_daemon(flash_mode="window", completed=completed)
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
|
||||
mock_alert.assert_called_once_with(completed)
|
||||
|
||||
def test_custom_colors_per_mode(self):
|
||||
"""Different colors for running vs completed are passed through."""
|
||||
running = StyleConfig(color="#FF0000")
|
||||
completed = StyleConfig(color="#00FF00")
|
||||
daemon = self._make_daemon(
|
||||
approval_delay=0.0, flash_mode="window",
|
||||
running=running, completed=completed,
|
||||
)
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.play_alert"):
|
||||
daemon._check_pending()
|
||||
|
||||
daemon.overlay.pulse.assert_called_once_with(
|
||||
[((0, 0), (800, 600))], running
|
||||
)
|
||||
|
||||
daemon.overlay.pulse.reset_mock()
|
||||
daemon._last_flash = 0
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.play_alert"):
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
|
||||
daemon.overlay.flash.assert_called_once_with(
|
||||
[((0, 0), (800, 600))], completed
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from cursor_flasher.detector import (
|
||||
CursorDetector,
|
||||
PollResult,
|
||||
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_text_means_agent_working(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Stop"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is True
|
||||
|
||||
def test_cancel_generating_means_agent_working(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Cancel generating"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is True
|
||||
|
||||
def test_accept_text_means_approval_needed(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Accept"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_reject_text_means_approval_needed(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Reject"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_run_this_time_means_approval_needed(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Run this time only (⏎)"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_both_signals(self):
|
||||
elements = [
|
||||
{"role": "AXStaticText", "value": "Stop"},
|
||||
{"role": "AXStaticText", "value": "Accept"},
|
||||
]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is True
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_irrelevant_text_ignored(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Settings"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is False
|
||||
assert signals.approval_needed is False
|
||||
|
||||
def test_button_role_also_detected(self):
|
||||
elements = [{"role": "AXButton", "title": "Accept"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_partial_match_on_run_command(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Run command"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
|
||||
class TestCursorDetector:
|
||||
def test_returns_none_when_cursor_not_running(self):
|
||||
detector = CursorDetector()
|
||||
with patch.object(detector, "_find_cursor_pid", return_value=None):
|
||||
result = detector.poll()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPollResult:
|
||||
def test_default_has_empty_active_windows(self):
|
||||
result = PollResult(signals=UISignals())
|
||||
assert result.active_windows == []
|
||||
assert result.signals.agent_working is False
|
||||
assert result.signals.approval_needed is False
|
||||
105
tests/test_hook.py
Normal file
105
tests/test_hook.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for the hook notification script."""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _short_sock_path():
|
||||
"""Create a short socket path that fits macOS's 104-char limit."""
|
||||
fd, path = tempfile.mkstemp(suffix=".sock", dir="/tmp")
|
||||
os.close(fd)
|
||||
os.unlink(path)
|
||||
return path
|
||||
|
||||
|
||||
def _run_hook_main(stdin_data: str, socket_path: str):
|
||||
"""Run the hook's main() with patched stdin and socket path."""
|
||||
import io
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks"))
|
||||
import notify
|
||||
|
||||
with patch.object(notify, "SOCKET_PATH", socket_path), \
|
||||
patch("sys.stdin", io.StringIO(stdin_data)):
|
||||
notify.main()
|
||||
|
||||
|
||||
class TestHookNotify:
|
||||
def test_sends_message_to_socket(self):
|
||||
sock_path = _short_sock_path()
|
||||
received = []
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
|
||||
def accept():
|
||||
conn, _ = server.accept()
|
||||
data = conn.recv(4096)
|
||||
received.append(json.loads(data))
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=accept)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
hook_input = json.dumps({
|
||||
"workspace_roots": ["/Users/me/project"],
|
||||
"hook_event_name": "preToolUse",
|
||||
"tool_name": "Shell",
|
||||
})
|
||||
_run_hook_main(hook_input, sock_path)
|
||||
t.join(timeout=2)
|
||||
finally:
|
||||
server.close()
|
||||
if os.path.exists(sock_path):
|
||||
os.unlink(sock_path)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0]["workspace"] == "/Users/me/project"
|
||||
assert received[0]["event"] == "preToolUse"
|
||||
assert received[0]["tool"] == "Shell"
|
||||
|
||||
def test_handles_missing_socket_gracefully(self):
|
||||
hook_input = json.dumps({
|
||||
"workspace_roots": ["/Users/me/project"],
|
||||
"hook_event_name": "stop",
|
||||
})
|
||||
_run_hook_main(hook_input, "/tmp/nonexistent.sock")
|
||||
|
||||
def test_handles_empty_workspace_roots(self):
|
||||
sock_path = _short_sock_path()
|
||||
received = []
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
|
||||
def accept():
|
||||
conn, _ = server.accept()
|
||||
data = conn.recv(4096)
|
||||
received.append(json.loads(data))
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=accept)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
hook_input = json.dumps({
|
||||
"workspace_roots": [],
|
||||
"hook_event_name": "stop",
|
||||
})
|
||||
_run_hook_main(hook_input, sock_path)
|
||||
t.join(timeout=2)
|
||||
finally:
|
||||
server.close()
|
||||
if os.path.exists(sock_path):
|
||||
os.unlink(sock_path)
|
||||
|
||||
assert received[0]["workspace"] == ""
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
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()
|
||||
@@ -1,82 +0,0 @@
|
||||
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()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
changed = sm.update(agent_working=False, approval_needed=False)
|
||||
assert sm.cooldown == 5.0
|
||||
|
||||
def test_stale_approval_from_idle_ignored(self):
|
||||
"""Approval buttons in IDLE state (stale chat history) must not trigger flash."""
|
||||
sm = StateMachine()
|
||||
changed = sm.update(agent_working=False, approval_needed=True)
|
||||
assert sm.state == FlasherState.IDLE
|
||||
assert changed is False
|
||||
|
||||
def test_approval_after_working_triggers(self):
|
||||
"""Approval buttons after seeing agent work should trigger flash."""
|
||||
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
|
||||
Reference in New Issue
Block a user