diff --git a/README.md b/README.md index 5cff44a..9024c38 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ **Smart Notifications for Claude Code on Desktop and Mobile** -Click notifications to instantly restore your exact Claude Code window across macOS Spacesβ€”not just the app, but your specific terminal or IDE window. +Click notifications to instantly restore your exact Claude Code context across macOS Spacesβ€”not just the app, but your specific terminal/IDE window (and iTerm2 tab when available). Also enables seamless πŸ“± mobile development via push notifications. ## Features -- **🎯 Click-to-Focus** - Restore exact window across Spaces, not just the app. When you have multiple terminal or IDE windows open, cc-notifier brings you back to the specific window where Claude Code is running. +- **🎯 Click-to-Focus** - Restore exact window across Spaces, not just the app. With iTerm2, cc-notifier also restores the original tab/session within that window. - **🧠 Intelligent Detection** - πŸ’» Desktop: notifies when you switch windows | 🌐 Remote: notifies when idle - **⚑ Fast & Async** - Runs in background, never blocks Claude Code - **πŸ“² Push Notifications** - Desktop: optional idle alerts | Remote: primary notification method (Pushover) @@ -107,8 +107,9 @@ Add to `~/.claude/settings.json`: 2. **Task Completion** β†’ Compares current window vs original window 3. **Smart Notification:** - πŸͺŸ **Switched windows?** β†’ Local notification with click-to-focus + - πŸ—‚οΈ **Switched iTerm2 tabs in same window?** β†’ Local notification with tab-aware click-to-focus - πŸ’€ **Idle at desk?** β†’ Optional push notification via Pushover -4. **Click Notification** β†’ Hammerspoon instantly restores your exact window across Spaces +4. **Click Notification** β†’ Hammerspoon restores your exact window across Spaces; iTerm2 sessions also restore the original tab ### 🌐 Remote Mode (SSH) diff --git a/cc_notifier.context.md b/cc_notifier.context.md index 51e850c..e9b5749 100644 --- a/cc_notifier.context.md +++ b/cc_notifier.context.md @@ -12,7 +12,7 @@ Primarily a high-level architectural reference, not a detailed implementation gu ## Key Components -- **Session Files**: `/tmp/cc_notifier/{session_id}` containing window ID, app path, timestamp, and tmux session ID +- **Session Files**: `/tmp/cc_notifier/{session_id}` containing window ID, app path, timestamp, tmux session ID, and optional iTerm2 session ID - **Window Management**: Hammerspoon CLI for cross-space window focusing - **Local Notifications**: terminal-notifier with `-execute` parameter for click actions - **Push Notifications**: Pushover API integration @@ -27,10 +27,11 @@ Flows are in the order they are executed, and are performed synchronously, unles **Flow**: 1. Parse session data from stdin JSON 2. **Desktop Mode**: Get focused window ID via Hammerspoon CLI (`hs.window.focusedWindow()`) + - If focused app is iTerm2: capture focused iTerm2 session ID via AppleScript for tab-level tracking **Remote Mode**: Use placeholder "REMOTE" (auto-detected via SSH environment variables) **Hammerspoon Missing**: Falls back to "UNAVAILABLE" placeholder (graceful degradation) 3. Capture tmux session ID via `tmux display-message -p '#{session_id}'` (both modes, None if not in tmux) -4. Save window ID, app path, timestamp, and tmux session ID to `/tmp/cc_notifier/{session_id}` +4. Save window ID, app path, timestamp, tmux session ID, and optional iTerm2 session ID to `/tmp/cc_notifier/{session_id}` 5. Exit immediately ### `cc-notifier notify` @@ -48,9 +49,11 @@ Flows are in the order they are executed, and are performed synchronously, unles - If window ID is available: - Get current focused window ID via Hammerspoon CLI - Compare original vs current window ID + - Same iTerm2 window + different iTerm2 session ID: User switched tabs, send notification - Same window + tmux session detached: User switched tmux sessions, send notification - Same window + tmux attached or no tmux: Don't send local notification, continue to push check - Different window: Send local notification via terminal-notifier with click-to-focus + - Click-to-focus restores original window; for iTerm2 sessions, it also restores the original tab/session - Local notification failures are caught so push notifications still fire - Update session timestamp 5. **Remote Mode Only**: Skip local notifications entirely @@ -105,9 +108,10 @@ Note: Claude Code sends additional fields (e.g., `transcript_path`) that are fil - (optional, empty string if not in tmux) + (optional, empty string if not in tmux) + (optional, only for iTerm2 desktop sessions) ``` -- 4th line is optional for backward compatibility β€” old 3-line session files still work +- 4th/5th lines are optional for backward compatibility β€” old 3-line and 4-line session files still work **Log Files** - Stored in `~/.cc-notifier/cc-notifier.log` diff --git a/cc_notifier.py b/cc_notifier.py index a4dd5fe..92a1351 100644 --- a/cc_notifier.py +++ b/cc_notifier.py @@ -101,17 +101,22 @@ def main() -> None: def cmd_init() -> None: """Initialize session by capturing focused window ID and app path.""" hook_data = HookData.from_stdin() + iterm2_session_id = "" if is_remote_session(): window_id, app_path = "REMOTE", "REMOTE" debug_log("Remote session detected, skipping window capture") else: try: window_id, app_path = get_focused_window_id() + if is_iterm2_app(app_path): + iterm2_session_id = get_iterm2_focused_session_id() except (RuntimeError, OSError) as e: window_id, app_path = "UNAVAILABLE", "UNAVAILABLE" debug_log(f"Window capture failed, continuing without: {e}") tmux_session_id = get_tmux_session_id() or "" - save_window_id(hook_data.session_id, window_id, app_path, tmux_session_id) + save_window_id( + hook_data.session_id, window_id, app_path, tmux_session_id, iterm2_session_id + ) @handle_command_errors("notify") @@ -128,6 +133,7 @@ def cmd_notify() -> None: original_window_id = lines[0] app_path = lines[1] tmux_session_id = lines[3] if len(lines) > 3 else "" + iterm2_session_id = lines[4] if len(lines) > 4 else "" # Set global app path for error handling _CURRENT_APP_PATH = app_path @@ -136,7 +142,11 @@ def cmd_notify() -> None: if not is_remote_session(): try: send_local_notification_if_needed( - hook_data, original_window_id, tmux_session_id + hook_data, + original_window_id, + app_path, + tmux_session_id, + iterm2_session_id, ) except (RuntimeError, OSError) as e: log_error("Local notification failed, continuing to push", e) @@ -223,9 +233,14 @@ def check_deduplication(session_file: Path) -> bool: < NOTIFICATION_DEDUPLICATION_THRESHOLD_SECONDS ): return True + app_path = lines[1] if len(lines) > 1 else "" tmux_id = lines[3] if len(lines) > 3 else "" + iterm2_session_id = lines[4] if len(lines) > 4 else "" f.seek(0) - f.write(f"{lines[0]}\n{lines[1]}\n{time.time()}\n{tmux_id}") + updated_content = f"{lines[0]}\n{app_path}\n{time.time()}\n{tmux_id}" + if iterm2_session_id: + updated_content += f"\n{iterm2_session_id}" + f.write(updated_content) f.truncate() return False except BlockingIOError: @@ -235,9 +250,17 @@ def check_deduplication(session_file: Path) -> bool: def send_local_notification_if_needed( hook_data: HookData, original_window_id: str, + app_path: str, tmux_session_id: str = "", + iterm2_session_id: str = "", ) -> None: - """Send local notification if user switched away from original window.""" + """Send local notification if user switched away from original window. + + Detects three "switched away" scenarios: + - User switched to a different window entirely + - User switched iTerm2 tabs within the same window + - User detached/switched tmux sessions within the same window + """ # Without Hammerspoon, check tmux session before sending if original_window_id == "UNAVAILABLE": if tmux_session_id and is_tmux_session_attached(tmux_session_id): @@ -250,9 +273,27 @@ def send_local_notification_if_needed( send_notification(title=title, subtitle=subtitle, message=message) return - current_window_id, _ = get_focused_window_id() + current_window_id, current_app_path = get_focused_window_id() + iterm2_tab_switched = False + + if ( + original_window_id == current_window_id + and iterm2_session_id + and is_iterm2_app(app_path) + and is_iterm2_app(current_app_path) + ): + current_iterm2_session_id = get_iterm2_focused_session_id() + if current_iterm2_session_id and current_iterm2_session_id != iterm2_session_id: + iterm2_tab_switched = True + debug_log( + "Same iTerm2 window but different session ID - user switched tabs" + ) + elif not current_iterm2_session_id: + debug_log( + "Unable to read current iTerm2 session ID - falling back to window/tmux detection" + ) - if original_window_id == current_window_id: + if original_window_id == current_window_id and not iterm2_tab_switched: # Same window, but check if user switched tmux sessions within it if tmux_session_id and not is_tmux_session_attached(tmux_session_id): debug_log( @@ -274,6 +315,7 @@ def send_local_notification_if_needed( subtitle=subtitle, message=message, focus_window_id=original_window_id, + focus_iterm2_session_id=iterm2_session_id if is_iterm2_app(app_path) else None, ) @@ -282,13 +324,17 @@ def save_window_id( window_id: str, app_path: str, tmux_session_id: str = "", + iterm2_session_id: str = "", ) -> None: - """Save window ID, app path, and tmux session ID to session file.""" + """Save window ID, app path, tmux, and optional iTerm2 session ID.""" SESSION_DIR.mkdir(exist_ok=True) session_file = SESSION_DIR / session_id - session_file.write_text(f"{window_id}\n{app_path}\n0\n{tmux_session_id}") + content = f"{window_id}\n{app_path}\n0\n{tmux_session_id}" + if iterm2_session_id: + content += f"\n{iterm2_session_id}" + session_file.write_text(content) debug_log( - f"Session initialized: window_id={window_id}, app_path={app_path}, tmux={tmux_session_id}, session_file={session_file}" + f"Session initialized: window_id={window_id}, app_path={app_path}, tmux={tmux_session_id}, iterm2_session={iterm2_session_id}, session_file={session_file}" ) @@ -495,7 +541,57 @@ def get_focused_window_id() -> tuple[str, str]: ) from e -def create_focus_command(window_id: str) -> list[str]: +def is_iterm2_app(app_path: str) -> bool: + """Return True when app path identifies iTerm2.""" + return app_path.endswith("/iTerm.app") or app_path.endswith("/iTerm2.app") + + +def get_iterm2_focused_session_id() -> str: + """Get iTerm2 focused session ID, or empty string when unavailable.""" + script_lines = [ + 'tell application "iTerm2"', + 'if not running then return ""', + "try", + "return id of current session of current window as text", + "on error", + 'return ""', + "end try", + "end tell", + ] + cmd = ["osascript"] + for line in script_lines: + cmd.extend(["-e", line]) + + try: + return run_command(cmd, timeout=5) + except (RuntimeError, subprocess.TimeoutExpired): + return "" + + +def _build_iterm2_restore_script(iterm2_session_id: str) -> str: + """Build AppleScript that focuses iTerm2 on a specific session ID.""" + escaped_session_id = iterm2_session_id.replace("\\", "\\\\").replace('"', '\\"') + return f"""tell application "iTerm2" +if not running then return +repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if (id of s as text) is "{escaped_session_id}" then + tell w to select + tell t to select + tell s to select + activate + return + end if + end repeat + end repeat +end repeat +end tell""" + + +def create_focus_command( + window_id: str, iterm2_session_id: Optional[str] = None +) -> list[str]: """ Create the Hammerspoon focus command for cross-space window focusing. @@ -505,8 +601,12 @@ def create_focus_command(window_id: str) -> list[str]: If the window cannot be found or focused, shows an error notification. + When iterm2_session_id is provided, chains an AppleScript command after + the Hammerspoon focus to restore the specific iTerm2 tab/session. + Args: window_id: The window ID to focus + iterm2_session_id: Optional iTerm2 session ID for tab restoration Returns: List of command arguments for subprocess execution @@ -525,7 +625,16 @@ def create_focus_command(window_id: str) -> list[str]: end end require('hs.notify').new({{title="cc-notifier", informativeText="Could not restore window focus. Try reopening your terminal or IDE.", soundName="Basso"}}):send()""" - return [HAMMERSPOON_CLI, "-c", focus_script] + if not iterm2_session_id: + return [HAMMERSPOON_CLI, "-c", focus_script] + + hs_cmd = [HAMMERSPOON_CLI, "-c", focus_script] + osascript_cmd = ["osascript", "-e", _build_iterm2_restore_script(iterm2_session_id)] + combined = ( + f"{' '.join(shlex.quote(arg) for arg in hs_cmd)}; " + f"{' '.join(shlex.quote(arg) for arg in osascript_cmd)}" + ) + return ["/bin/sh", "-c", combined] # ============================================================================ @@ -627,7 +736,11 @@ def create_notification_data( def send_notification( - title: str, subtitle: str, message: str, focus_window_id: Optional[str] = None + title: str, + subtitle: str, + message: str, + focus_window_id: Optional[str] = None, + focus_iterm2_session_id: Optional[str] = None, ) -> None: """Send a macOS notification with optional click-to-focus functionality.""" cmd = [ @@ -645,7 +758,7 @@ def send_notification( # Add click-to-focus functionality if window ID provided if focus_window_id: - focus_cmd = create_focus_command(focus_window_id) + focus_cmd = create_focus_command(focus_window_id, focus_iterm2_session_id) execute_cmd = " ".join(shlex.quote(arg) for arg in focus_cmd) cmd.extend(["-execute", execute_cmd]) diff --git a/tests/test_core.py b/tests/test_core.py index cc6c1ee..a66ed58 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -110,7 +110,11 @@ def test_debug_flag_parsing(self, tmp_path): mock_stdin.assert_called_once() mock_window.assert_called_once() mock_save.assert_called_once_with( - "test", "12345", "/System/Applications/Utilities/Terminal.app", "" + "test", + "12345", + "/System/Applications/Utilities/Terminal.app", + "", + "", ) finally: @@ -254,6 +258,34 @@ def test_init_workflow_without_hammerspoon(self, tmp_path): assert lines[2] == "0" assert lines[3] == "$5" # tmux session ID still captured + def test_init_workflow_captures_iterm2_session_id(self, tmp_path): + """Test init captures iTerm2 session ID for tab-level restoration.""" + test_input = {"session_id": "iterm123", "cwd": "/test/path"} + session_dir = tmp_path / "cc_notifier" + + with ( + patch( + "cc_notifier.get_focused_window_id", + return_value=("54321", "/Applications/iTerm.app"), + ), + patch("cc_notifier.get_iterm2_focused_session_id", return_value="w0t1p1"), + patch("cc_notifier.get_tmux_session_id", return_value="$20"), + patch("sys.stdin", StringIO(json.dumps(test_input))), + patch.object(sys, "argv", ["cc-notifier", "init"]), + patch.object(cc_notifier, "SESSION_DIR", session_dir), + patch.dict(os.environ, {"CC_NOTIFIER_WRAPPER": "1"}), + ): + cc_notifier.main() + + session_file = session_dir / "iterm123" + assert session_file.exists() + lines = session_file.read_text().strip().split("\n") + assert lines[0] == "54321" + assert lines[1] == "/Applications/iTerm.app" + assert lines[2] == "0" + assert lines[3] == "$20" + assert lines[4] == "w0t1p1" + def test_notify_suppressed_when_tmux_attached_without_hammerspoon(self, tmp_path): """Test notify suppresses local notification when tmux session is attached.""" test_input = {"session_id": "nohammer", "cwd": "/test/project"} @@ -459,6 +491,45 @@ def test_notify_sent_when_same_window_but_tmux_detached(self, tmp_path): ] assert len(terminal_notifier_calls) >= 1 + def test_notify_sent_when_same_iterm2_window_but_different_tab(self, tmp_path): + """Test notify sends local notification when iTerm2 tab changed in same window.""" + test_input = {"session_id": "notify123", "cwd": "/test/project"} + session_dir = tmp_path / "cc_notifier" + session_dir.mkdir() + (session_dir / "notify123").write_text( + "same123\n/Applications/iTerm.app\n0\n$20\nw0t0p0" + ) + + with ( + patch( + "cc_notifier.get_focused_window_id", + return_value=("same123", "/Applications/iTerm.app"), + ), + patch("cc_notifier.get_iterm2_focused_session_id", return_value="w0t1p0"), + patch("cc_notifier.run_background_command") as mock_bg, + patch("sys.stdin", StringIO(json.dumps(test_input))), + patch.object(sys, "argv", ["cc-notifier", "notify"]), + patch.object(cc_notifier, "SESSION_DIR", session_dir), + patch("cc_notifier.PushConfig.from_env", return_value=None), + patch.dict(os.environ, {"CC_NOTIFIER_WRAPPER": "1"}), + ): + cc_notifier.main() + + assert mock_bg.call_count >= 1 + bg_calls = [call[0][0] for call in mock_bg.call_args_list] + terminal_notifier_calls = [ + cmd + for cmd in bg_calls + if any("terminal-notifier" in str(arg) for arg in cmd) + ] + assert len(terminal_notifier_calls) >= 1 + + # Verify iTerm2 restore script is included in -execute chain + execute_args = terminal_notifier_calls[0] + execute_idx = execute_args.index("-execute") + assert "osascript" in execute_args[execute_idx + 1] + assert "w0t0p0" in execute_args[execute_idx + 1] + def test_cleanup_workflow_removes_session(self, tmp_path): """Test complete cleanup workflow: JSON input β†’ real age-based file cleanup.""" test_input = {"session_id": "cleanup123"} @@ -520,6 +591,20 @@ def test_wrapper_performance(self): f"Wrapper took {duration_ms:.1f}ms, expected <{MAX_WRAPPER_DURATION_MS}ms" ) + def test_dedup_preserves_iterm2_session_id(self, tmp_path): + """check_deduplication must preserve the iTerm2 session ID on rewrite.""" + session_dir = tmp_path / "cc_notifier" + session_dir.mkdir() + session_file = session_dir / "dedup-iterm" + # Timestamp old enough to bypass the dedup window + session_file.write_text("win123\n/Applications/iTerm.app\n0\n$20\nw0t1p0") + + assert cc_notifier.check_deduplication(session_file) is False + + lines = session_file.read_text().strip().split("\n") + assert len(lines) == 5 + assert lines[4] == "w0t1p0" + def test_file_locking_prevents_race_conditions(self, tmp_path): """Test file locking prevents race conditions between concurrent processes.""" # Setup session file with 4-line format diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 39983fc..d881e88 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -117,6 +117,17 @@ def test_create_focus_command_generates_correct_script(self): assert "w:focus()" in command[2] assert "hs.window.filter" in command[2] + def test_create_focus_command_includes_iterm2_tab_restore(self): + """Test create_focus_command() includes iTerm2 tab restore when session ID provided.""" + command = cc_notifier.create_focus_command("12345", "w0t1p0") + + assert len(command) == 3 + assert command[0] == "/bin/sh" + assert command[1] == "-c" + assert "hs.window.filter" in command[2] + assert "osascript" in command[2] + assert "w0t1p0" in command[2] + @patch("subprocess.Popen") def test_terminal_notifier_command_construction(self, mock_popen): """Test proper command construction for notification scenarios.""" @@ -177,3 +188,24 @@ def test_basic_error_logging_functionality(self, tmp_path): content = log_file.read_text() assert "Test error" in content assert "ValueError: test" in content + + +class TestITerm2Integration: + """Test iTerm2 detection and session capture helpers.""" + + def test_is_iterm2_app_detection(self): + """Test iTerm2 app path detection for iTerm and iTerm2 variants.""" + assert cc_notifier.is_iterm2_app("/Applications/iTerm.app") + assert cc_notifier.is_iterm2_app("/Applications/iTerm2.app") + assert not cc_notifier.is_iterm2_app( + "/System/Applications/Utilities/Terminal.app" + ) + + @patch("cc_notifier.run_command") + def test_get_iterm2_focused_session_id(self, mock_run_command): + """Test iTerm2 focused session ID capture and fallback behavior.""" + mock_run_command.return_value = "w0t0p0" + assert cc_notifier.get_iterm2_focused_session_id() == "w0t0p0" + + mock_run_command.side_effect = RuntimeError("osascript failed") + assert cc_notifier.get_iterm2_focused_session_id() == "" diff --git a/tests/tests.context.md b/tests/tests.context.md index b40eb19..7f14012 100644 --- a/tests/tests.context.md +++ b/tests/tests.context.md @@ -8,13 +8,13 @@ Associated with: all tests in the codebase **Format**: `test_name` - [concise description of what's being tested] - [rationale for why test is needed] -**Status**: **65 total tests** (59 core + 6 integration) across 2 files - All tests properly accounted for and documented +**Status**: **71 total tests** (62 core + 9 integration) across 2 files - All tests properly accounted for and documented **Structure**: Tests are organized by functionality and concerns, emphasizing behavior-focused testing over implementation details. The 2-file structure matches the natural architectural boundary between core logic and external system integration. --- -## test_core.py (59 tests) - Core Functionality & Essential Business Logic +## test_core.py (62 tests) - Core Functionality & Essential Business Logic ### TestCLIInterface (9 tests) - Essential CLI Contract Testing - `test_main_with_no_args_exits_with_error` - CLI error handling when no command provided - CLI must provide helpful usage info and exit gracefully @@ -27,9 +27,10 @@ Associated with: all tests in the codebase - `test_main_blocks_direct_execution_without_wrapper_env` - Prevents direct execution without wrapper environment variable - Critical for preventing Claude Code hooks from blocking - `test_main_allows_execution_with_wrapper_env` - Allows execution when wrapper environment variable is set - Ensures proper wrapper integration works correctly -### TestCoreWorkflows (12 tests) - End-to-End Workflow Validation +### TestCoreWorkflows (15 tests) - End-to-End Workflow Validation - `test_init_workflow_captures_and_saves_window` - Complete init workflow from JSON input to file creation including tmux session ID - End-to-end validation of session initialization - `test_init_workflow_without_hammerspoon` - Init falls back to UNAVAILABLE but still captures tmux session ID - Validates graceful degradation +- `test_init_workflow_captures_iterm2_session_id` - Init captures iTerm2 focused session ID alongside window metadata - Enables same-window tab restoration for iTerm2 - `test_notify_suppressed_when_tmux_attached_without_hammerspoon` - Notify suppresses local notification when tmux session is attached - Prevents false positives in tmux - `test_notify_sent_when_tmux_detached_without_hammerspoon` - Notify sends local notification when tmux session is detached - Ensures notifications when user truly away - `test_notify_sent_without_hammerspoon_or_tmux` - Notify sends unconditionally when neither Hammerspoon nor tmux available - Fallback behavior @@ -38,6 +39,8 @@ Associated with: all tests in the codebase - `test_cleanup_workflow_removes_session` - Complete cleanup workflow with age-based file removal - End-to-end validation of session cleanup functionality - `test_wrapper_performance` - Bash wrapper returns immediately without waiting for Python - Critical for non-blocking hook execution in Claude Code - `test_notify_sent_when_same_window_but_tmux_detached` - Notify sends notification when same window but user switched tmux sessions - Detects intra-window tmux session switches +- `test_notify_sent_when_same_iterm2_window_but_different_tab` - Notify sends local notification when iTerm2 tab changed in same window - Enables tab-level away detection in iTerm2 +- `test_dedup_preserves_iterm2_session_id` - check_deduplication preserves iTerm2 session ID on timestamp rewrite - Prevents silent loss of tab restore on second-and-later notifications - `test_file_locking_prevents_race_conditions` - File locking prevents race conditions and preserves tmux session ID - Essential for preventing duplicate notifications - `test_push_uses_extended_intervals_when_tmux_attached_desktop` - Desktop mode uses extended idle check intervals when tmux attached - Ensures attached tmux sessions use attached idle check intervals @@ -92,7 +95,7 @@ Associated with: all tests in the codebase --- -## test_integrations.py (6 tests) - External System Boundaries & Integration Testing +## test_integrations.py (9 tests) - External System Boundaries & Integration Testing ### TestHammerspoonIntegration (1 test) - Consolidated External System Testing - `test_hammerspoon_cli_integration` - Hammerspoon CLI success, timeout, and error scenarios - Comprehensive testing of window management integration in a single consolidated test @@ -101,7 +104,12 @@ Associated with: all tests in the codebase - `test_json_parsing_error_recovery` - Error handling for malformed JSON from Claude Code hooks - Prevents undefined behavior with bad hook data - `test_corrupted_session_file_handling` - Error handling for corrupted/unreadable session files - Prevents undefined behavior with bad file data -### TestNotificationSystemIntegration (3 tests) - Notification Boundary Testing +### TestNotificationSystemIntegration (4 tests) - Notification Boundary Testing - `test_create_focus_command_generates_correct_script` - Focus command generation for window restoration - Essential for click-to-focus functionality +- `test_create_focus_command_includes_iterm2_tab_restore` - Focus command adds iTerm2 tab/session restore step when session ID exists - Ensures click-to-focus can restore exact iTerm2 tab - `test_terminal_notifier_command_construction` - Command construction for notification scenarios and focus parameters - Validates command-line argument generation and click-to-focus integration -- `test_basic_error_logging_functionality` - Basic error logging to file with error details - Essential for troubleshooting issues in production \ No newline at end of file +- `test_basic_error_logging_functionality` - Basic error logging to file with error details - Essential for troubleshooting issues in production + +### TestITerm2Integration (2 tests) - iTerm2-Specific Integration Testing +- `test_is_iterm2_app_detection` - Detects iTerm2 app paths reliably - Gates iTerm2-only tab logic without affecting other apps +- `test_get_iterm2_focused_session_id` - Captures focused iTerm2 session ID with graceful fallback - Ensures robust tab identity capture for notifications \ No newline at end of file