diff --git a/README.md b/README.md index 204db8e..9b823d5 100644 --- a/README.md +++ b/README.md @@ -49,26 +49,44 @@ uv run cursor-flasher stop Optional config file at `~/.cursor-flasher/config.yaml`: ```yaml -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 +theme: "auto" # "dark", "light", or "auto" (follows macOS appearance) -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 +dark: # styles used when OS is in dark mode + 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 + 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 + +light: # styles used when OS is in light mode + running: + color: "#3B82F6" + width: 4 + opacity: 0.9 + pulse_speed: 1.5 + sound: "Glass" + volume: 0.5 + completed: + color: "#22C55E" + width: 4 + opacity: 0.9 + duration: 1.5 + sound: "" + volume: 0.0 flash: - mode: "screen" # "window", "screen", or "allscreens" + mode: "screen" # "window", "screen", or "allscreens" # Tools that trigger the pulse + sound (approval mode). # Others are silently ignored (e.g., Read, Grep, Glob, Task). @@ -78,11 +96,11 @@ approval_tools: - Delete general: - approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals) + approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals) cooldown: 2.0 # minimum seconds between flashes ``` -Each mode (`running` and `completed`) has its own color, border style, and sound settings. Set `sound: ""` to disable sound for a particular mode. +Styles are organized under `dark` and `light` theme sections, each containing `running` (approval pulse) and `completed` (stop flash) modes with their own color, border, and sound settings. The `theme` option controls which styles are active: set `"auto"` to follow macOS appearance in real-time, or force `"dark"` / `"light"`. Set `sound: ""` to disable sound for a particular mode. ## Uninstall diff --git a/src/cursor_flasher/config.py b/src/cursor_flasher/config.py index bcbc21c..4fad7da 100644 --- a/src/cursor_flasher/config.py +++ b/src/cursor_flasher/config.py @@ -15,6 +15,8 @@ STYLE_FIELDS = { "volume": float, } +VALID_THEMES = {"dark", "light", "auto"} + @dataclass class StyleConfig: @@ -36,10 +38,19 @@ def _default_completed() -> StyleConfig: @dataclass -class Config: +class ThemeStyles: + """Running and completed styles for a single theme (dark or light).""" + running: StyleConfig = field(default_factory=_default_running) completed: StyleConfig = field(default_factory=_default_completed) + +@dataclass +class Config: + dark: ThemeStyles = field(default_factory=ThemeStyles) + light: ThemeStyles = field(default_factory=ThemeStyles) + + theme: str = "auto" flash_mode: str = "screen" approval_tools: list[str] = field( @@ -49,6 +60,16 @@ class Config: approval_delay: float = 2.5 cooldown: float = 2.0 + def active_styles(self, system_appearance: str) -> ThemeStyles: + """Return the ThemeStyles matching the current theme setting. + + Args: + system_appearance: "dark" or "light" as detected from the OS. + """ + if self.theme == "auto": + return self.dark if system_appearance == "dark" else self.light + return self.dark if self.theme == "dark" else self.light + GENERAL_FIELD_MAP: dict[str, str] = { "approval_delay": "approval_delay", @@ -77,6 +98,21 @@ def _parse_style(raw_section: dict, defaults: StyleConfig) -> StyleConfig: ) +def _parse_theme_styles(raw_section: dict) -> ThemeStyles: + """Build a ThemeStyles from a YAML theme section (dark or light).""" + kwargs: dict[str, StyleConfig] = {} + + running_raw = raw_section.get("running") + if isinstance(running_raw, dict): + kwargs["running"] = _parse_style(running_raw, _default_running()) + + completed_raw = raw_section.get("completed") + if isinstance(completed_raw, dict): + kwargs["completed"] = _parse_style(completed_raw, _default_completed()) + + return ThemeStyles(**kwargs) + + def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: """Load config from YAML, falling back to defaults for missing values.""" if not path.exists(): @@ -90,13 +126,17 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: config_kwargs: dict[str, Any] = {} - running_raw = raw.get("running") - if isinstance(running_raw, dict): - config_kwargs["running"] = _parse_style(running_raw, _default_running()) + theme = raw.get("theme") + if isinstance(theme, str) and theme in VALID_THEMES: + config_kwargs["theme"] = theme - completed_raw = raw.get("completed") - if isinstance(completed_raw, dict): - config_kwargs["completed"] = _parse_style(completed_raw, _default_completed()) + dark_raw = raw.get("dark") + if isinstance(dark_raw, dict): + config_kwargs["dark"] = _parse_theme_styles(dark_raw) + + light_raw = raw.get("light") + if isinstance(light_raw, dict): + config_kwargs["light"] = _parse_theme_styles(light_raw) flash_raw = raw.get("flash") if isinstance(flash_raw, dict) and "mode" in flash_raw: diff --git a/src/cursor_flasher/daemon.py b/src/cursor_flasher/daemon.py index b177d57..50ffc2d 100644 --- a/src/cursor_flasher/daemon.py +++ b/src/cursor_flasher/daemon.py @@ -33,6 +33,13 @@ SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock") INPUT_DISMISS_GRACE = 0.5 +def _get_system_appearance() -> str: + """Return "dark" or "light" based on the current macOS appearance.""" + app = NSApplication.sharedApplication() + name = app.effectiveAppearance().name() + return "dark" if "Dark" in name else "light" + + class _PendingApproval: """An approval trigger waiting for the delay to expire before pulsing.""" __slots__ = ("workspace", "tool", "timestamp") @@ -125,14 +132,15 @@ class FlasherDaemon: return frames = self._resolve_frames(window["frame"]) + styles = self.config.active_styles(_get_system_appearance()) 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.overlay.pulse(frames, styles.running) self._pulse_started_at = time.monotonic() self._cursor_was_frontmost = is_cursor_frontmost() - play_alert(self.config.running) + play_alert(styles.running) self._last_flash = time.monotonic() def _check_input_dismiss(self) -> None: @@ -252,10 +260,11 @@ class FlasherDaemon: if window is None: return + styles = self.config.active_styles(_get_system_appearance()) 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.overlay.flash(frames, styles.completed) + play_alert(styles.completed) self._last_flash = now def _resolve_frames(self, window_frame: tuple) -> list[tuple]: diff --git a/tests/test_config.py b/tests/test_config.py index 577a936..53851a4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,23 +1,42 @@ -from cursor_flasher.config import Config, StyleConfig, load_config +from cursor_flasher.config import ( + Config, + StyleConfig, + ThemeStyles, + load_config, +) class TestDefaultConfig: - def test_running_defaults(self): + def test_dark_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 + assert c.dark.running.color == "#FF9500" + assert c.dark.running.width == 4 + assert c.dark.running.duration == 1.5 + assert c.dark.running.opacity == 0.85 + assert c.dark.running.pulse_speed == 1.5 + assert c.dark.running.sound == "Glass" + assert c.dark.running.volume == 0.5 - def test_completed_defaults(self): + def test_dark_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 + assert c.dark.completed.color == "#FF9500" + assert c.dark.completed.width == 4 + assert c.dark.completed.sound == "" + assert c.dark.completed.volume == 0.0 + + def test_light_running_defaults(self): + c = Config() + assert c.light.running.color == "#FF9500" + assert c.light.running.sound == "Glass" + + def test_light_completed_defaults(self): + c = Config() + assert c.light.completed.sound == "" + assert c.light.completed.volume == 0.0 + + def test_theme_defaults_to_auto(self): + c = Config() + assert c.theme == "auto" def test_has_approval_tools(self): c = Config() @@ -32,6 +51,41 @@ class TestDefaultConfig: assert c.flash_mode == "screen" +class TestActiveStyles: + def test_auto_returns_dark_when_system_dark(self): + c = Config(theme="auto") + dark_styles = ThemeStyles(running=StyleConfig(color="#111111")) + light_styles = ThemeStyles(running=StyleConfig(color="#EEEEEE")) + c.dark = dark_styles + c.light = light_styles + assert c.active_styles("dark").running.color == "#111111" + + def test_auto_returns_light_when_system_light(self): + c = Config(theme="auto") + dark_styles = ThemeStyles(running=StyleConfig(color="#111111")) + light_styles = ThemeStyles(running=StyleConfig(color="#EEEEEE")) + c.dark = dark_styles + c.light = light_styles + assert c.active_styles("light").running.color == "#EEEEEE" + + def test_explicit_dark_ignores_system(self): + c = Config(theme="dark") + c.dark = ThemeStyles(running=StyleConfig(color="#111111")) + c.light = ThemeStyles(running=StyleConfig(color="#EEEEEE")) + assert c.active_styles("light").running.color == "#111111" + + def test_explicit_light_ignores_system(self): + c = Config(theme="light") + c.dark = ThemeStyles(running=StyleConfig(color="#111111")) + c.light = ThemeStyles(running=StyleConfig(color="#EEEEEE")) + assert c.active_styles("dark").running.color == "#EEEEEE" + + def test_active_styles_includes_completed(self): + c = Config(theme="dark") + c.dark = ThemeStyles(completed=StyleConfig(color="#AA0000")) + assert c.active_styles("light").completed.color == "#AA0000" + + class TestLoadConfig: def test_missing_file_returns_defaults(self, tmp_path): c = load_config(tmp_path / "nope.yaml") @@ -43,26 +97,80 @@ class TestLoadConfig: c = load_config(p) assert c == Config() - def test_loads_running_overrides(self, tmp_path): + def test_loads_theme(self, tmp_path): p = tmp_path / "config.yaml" - p.write_text( - "running:\n color: '#00FF00'\n duration: 2.0\n sound: Ping\n" - ) + p.write_text("theme: dark\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 + assert c.theme == "dark" - def test_loads_completed_overrides(self, tmp_path): + def test_invalid_theme_uses_default(self, tmp_path): + p = tmp_path / "config.yaml" + p.write_text("theme: neon\n") + c = load_config(p) + assert c.theme == "auto" + + def test_loads_dark_running_overrides(self, tmp_path): p = tmp_path / "config.yaml" p.write_text( - "completed:\n color: '#0000FF'\n sound: Hero\n volume: 0.8\n" + "dark:\n" + " running:\n" + " color: '#00FF00'\n" + " duration: 2.0\n" + " sound: Ping\n" ) c = load_config(p) - assert c.completed.color == "#0000FF" - assert c.completed.sound == "Hero" - assert c.completed.volume == 0.8 + assert c.dark.running.color == "#00FF00" + assert c.dark.running.duration == 2.0 + assert c.dark.running.sound == "Ping" + assert c.dark.running.width == 4 + + def test_loads_dark_completed_overrides(self, tmp_path): + p = tmp_path / "config.yaml" + p.write_text( + "dark:\n" + " completed:\n" + " color: '#0000FF'\n" + " sound: Hero\n" + " volume: 0.8\n" + ) + c = load_config(p) + assert c.dark.completed.color == "#0000FF" + assert c.dark.completed.sound == "Hero" + assert c.dark.completed.volume == 0.8 + + def test_loads_light_running_overrides(self, tmp_path): + p = tmp_path / "config.yaml" + p.write_text( + "light:\n" + " running:\n" + " color: '#3B82F6'\n" + " opacity: 0.9\n" + ) + c = load_config(p) + assert c.light.running.color == "#3B82F6" + assert c.light.running.opacity == 0.9 + assert c.light.running.width == 4 + + def test_missing_dark_section_uses_defaults(self, tmp_path): + p = tmp_path / "config.yaml" + p.write_text( + "light:\n" + " running:\n" + " color: '#FFFFFF'\n" + ) + c = load_config(p) + assert c.dark == ThemeStyles() + + def test_missing_running_within_theme_uses_defaults(self, tmp_path): + p = tmp_path / "config.yaml" + p.write_text( + "dark:\n" + " completed:\n" + " color: '#FF0000'\n" + ) + c = load_config(p) + assert c.dark.running == StyleConfig() + assert c.dark.completed.color == "#FF0000" def test_loads_flash_mode(self, tmp_path): p = tmp_path / "config.yaml" @@ -86,16 +194,25 @@ class TestLoadConfig: 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" + "theme: light\n" + "dark:\n" + " 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" + "light:\n" + " running:\n" + " color: '#3B82F6'\n" + " width: 3\n" + " completed:\n" + " color: '#22C55E'\n" + " duration: 2.0\n" "flash:\n" " mode: window\n" "general:\n" @@ -105,14 +222,19 @@ class TestLoadConfig: " - 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.theme == "light" + assert c.dark.running.color == "#FF0000" + assert c.dark.running.width == 6 + assert c.dark.running.opacity == 0.9 + assert c.dark.running.pulse_speed == 2.0 + assert c.dark.running.sound == "Glass" + assert c.dark.running.volume == 0.8 + assert c.dark.completed.color == "#00FF00" + assert c.dark.completed.sound == "" + assert c.light.running.color == "#3B82F6" + assert c.light.running.width == 3 + assert c.light.completed.color == "#22C55E" + assert c.light.completed.duration == 2.0 assert c.flash_mode == "window" assert c.approval_delay == 1.0 assert c.cooldown == 3.0 diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 06e9a02..3d60357 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -4,10 +4,13 @@ import time from unittest.mock import patch, MagicMock -from cursor_flasher.config import Config, StyleConfig +from cursor_flasher.config import Config, StyleConfig, ThemeStyles from cursor_flasher.daemon import FlasherDaemon +PATCH_APPEARANCE = "cursor_flasher.daemon._get_system_appearance" + + class TestFlasherDaemon: def _make_daemon(self, **config_overrides) -> FlasherDaemon: config = Config(**config_overrides) @@ -39,10 +42,11 @@ class TestFlasherDaemon: 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"): + patch("cursor_flasher.daemon.play_alert"), \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._check_pending() - daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.running) + daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.dark.running) def test_postToolUse_cancels_pending(self): """Auto-approved tools: postToolUse arrives before delay, cancels the pulse.""" @@ -79,7 +83,8 @@ class TestFlasherDaemon: 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"): + patch("cursor_flasher.daemon.play_alert"), \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._check_pending() daemon.overlay.pulse.assert_called_once() @@ -91,25 +96,27 @@ class TestFlasherDaemon: 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"): + patch("cursor_flasher.daemon.play_alert"), \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._handle_message( json.dumps({"workspace": "/path", "event": "stop"}).encode() ) - daemon.overlay.flash.assert_called_once_with([screen], daemon.config.completed) + daemon.overlay.flash.assert_called_once_with([screen], daemon.config.dark.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"): + patch("cursor_flasher.daemon.play_alert"), \ + patch(PATCH_APPEARANCE, return_value="light"): daemon._handle_message( json.dumps({"workspace": "/path", "event": "stop"}).encode() ) daemon.overlay.flash.assert_called_once_with( - [((0, 0), (800, 600))], daemon.config.completed + [((0, 0), (800, 600))], daemon.config.light.completed ) def test_allscreens_mode_uses_all_screens(self): @@ -124,10 +131,11 @@ class TestFlasherDaemon: 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"): + patch("cursor_flasher.daemon.play_alert"), \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._check_pending() - daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.running) + daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.dark.running) def test_stop_dismisses_active_pulse(self): daemon = self._make_daemon() @@ -309,7 +317,8 @@ class TestFlasherDaemon: 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) + dark = ThemeStyles(running=running) + daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", dark=dark) window = {"title": "proj", "frame": ((0, 0), (800, 600))} daemon._handle_message( @@ -318,7 +327,8 @@ class TestFlasherDaemon: 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: + patch("cursor_flasher.daemon.play_alert") as mock_alert, \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._check_pending() mock_alert.assert_called_once_with(running) @@ -326,11 +336,13 @@ class TestFlasherDaemon: 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) + dark = ThemeStyles(completed=completed) + daemon = self._make_daemon(flash_mode="window", dark=dark) 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: + patch("cursor_flasher.daemon.play_alert") as mock_alert, \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._handle_message( json.dumps({"workspace": "/path", "event": "stop"}).encode() ) @@ -340,11 +352,13 @@ class TestFlasherDaemon: 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) + dark = ThemeStyles(completed=completed) + daemon = self._make_daemon(flash_mode="window", dark=dark) 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: + patch("cursor_flasher.daemon.play_alert") as mock_alert, \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._handle_message( json.dumps({"workspace": "/path", "event": "stop"}).encode() ) @@ -355,9 +369,9 @@ class TestFlasherDaemon: """Different colors for running vs completed are passed through.""" running = StyleConfig(color="#FF0000") completed = StyleConfig(color="#00FF00") + dark = ThemeStyles(running=running, completed=completed) daemon = self._make_daemon( - approval_delay=0.0, flash_mode="window", - running=running, completed=completed, + approval_delay=0.0, flash_mode="window", dark=dark, ) window = {"title": "proj", "frame": ((0, 0), (800, 600))} @@ -367,7 +381,8 @@ class TestFlasherDaemon: 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"): + patch("cursor_flasher.daemon.play_alert"), \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._check_pending() daemon.overlay.pulse.assert_called_once_with( @@ -378,7 +393,8 @@ class TestFlasherDaemon: daemon._last_flash = 0 with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ - patch("cursor_flasher.daemon.play_alert"): + patch("cursor_flasher.daemon.play_alert"), \ + patch(PATCH_APPEARANCE, return_value="dark"): daemon._handle_message( json.dumps({"workspace": "/path", "event": "stop"}).encode() ) @@ -386,3 +402,29 @@ class TestFlasherDaemon: daemon.overlay.flash.assert_called_once_with( [((0, 0), (800, 600))], completed ) + + def test_theme_auto_uses_light_when_system_light(self): + """Auto theme resolves to light styles when system is in light mode.""" + dark_running = StyleConfig(color="#111111") + light_running = StyleConfig(color="#EEEEEE") + daemon = self._make_daemon( + approval_delay=0.0, flash_mode="window", + dark=ThemeStyles(running=dark_running), + light=ThemeStyles(running=light_running), + theme="auto", + ) + 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"), \ + patch(PATCH_APPEARANCE, return_value="light"): + daemon._check_pending() + + daemon.overlay.pulse.assert_called_once_with( + [((0, 0), (800, 600))], light_running + )