From 3ccd4829cdecae6dbc9ead4d71380ac27abfd2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?deniz=20g=C3=B6k=C3=A7in?= <33603535+dgokcin@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:24:54 +0200 Subject: [PATCH 1/5] feat(iterm2): add tab-level notification and click-to-focus - capture iterm2 session id at init for tab identity tracking - detect tab switches in same window via session id comparison - extend click-to-focus to restore original iterm2 tab via applescript - preserve iterm2 session id through deduplication file rewrites - add tests for init capture, tab-switch detection, focus command --- cc_notifier.py | 136 +++++++++++++++++++++++++++++++++---- tests/test_core.py | 61 +++++++++++++++++ tests/test_integrations.py | 32 +++++++++ 3 files changed, 217 insertions(+), 12 deletions(-) diff --git a/cc_notifier.py b/cc_notifier.py index a4dd5fe..3aef575 100644 --- a/cc_notifier.py +++ b/cc_notifier.py @@ -101,17 +101,29 @@ 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) + if iterm2_session_id: + save_window_id( + hook_data.session_id, + window_id, + app_path, + tmux_session_id, + iterm2_session_id, + ) + else: + save_window_id(hook_data.session_id, window_id, app_path, tmux_session_id) @handle_command_errors("notify") @@ -128,6 +140,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 +149,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 +240,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,7 +257,9 @@ 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.""" # Without Hammerspoon, check tmux session before sending @@ -250,9 +274,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 +316,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 +325,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 +542,59 @@ 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. @@ -525,7 +624,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 +735,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 +757,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..d442adb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -254,6 +254,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 +487,39 @@ 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 + 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"} 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() == "" From f8dcbeec276cee18cbe58a92ebeb9350f876f946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?deniz=20g=C3=B6k=C3=A7in?= <33603535+dgokcin@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:25:00 +0200 Subject: [PATCH 2/5] docs: update readme and context for iterm2 tab support - mention iterm2 tab restore in readme features and how-it-works - document iterm2 session id in session file format - update context flows for init/notify with iterm2 tab tracking - sync test context count to 70 tests (61 core + 9 integration) --- README.md | 7 ++++--- cc_notifier.context.md | 12 ++++++++---- tests/tests.context.md | 17 ++++++++++++----- 3 files changed, 24 insertions(+), 12 deletions(-) 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/tests/tests.context.md b/tests/tests.context.md index b40eb19..4e2e12b 100644 --- a/tests/tests.context.md +++ b/tests/tests.context.md @@ -8,7 +8,7 @@ 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**: **70 total tests** (61 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. @@ -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 (14 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,7 @@ 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_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 +94,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 +103,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 From 120292b6e873babbf6c4e610cd81abf3a8aaf22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?deniz=20g=C3=B6k=C3=A7in?= <33603535+dgokcin@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:39:29 +0200 Subject: [PATCH 3/5] refactor(iterm2): simplify init save call and clean up string quoting - collapse conditional save_window_id call into single unconditional call - fix applescript string quoting to use consistent single-quoted style - collapse multi-line escaped_session_id assignment to one line - update test assertion to match new save_window_id signature --- cc_notifier.py | 21 ++++++--------------- tests/test_core.py | 6 +++++- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/cc_notifier.py b/cc_notifier.py index 3aef575..590dd45 100644 --- a/cc_notifier.py +++ b/cc_notifier.py @@ -114,16 +114,9 @@ def cmd_init() -> None: window_id, app_path = "UNAVAILABLE", "UNAVAILABLE" debug_log(f"Window capture failed, continuing without: {e}") tmux_session_id = get_tmux_session_id() or "" - if iterm2_session_id: - save_window_id( - hook_data.session_id, - window_id, - app_path, - tmux_session_id, - iterm2_session_id, - ) - else: - 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") @@ -551,11 +544,11 @@ 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 \"\"", + 'if not running then return ""', "try", "return id of current session of current window as text", "on error", - "return \"\"", + 'return ""', "end try", "end tell", ] @@ -571,9 +564,7 @@ def get_iterm2_focused_session_id() -> str: 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('"', '\\"') - ) + escaped_session_id = iterm2_session_id.replace("\\", "\\\\").replace('"', '\\"') return f"""tell application "iTerm2" if not running then return repeat with w in windows diff --git a/tests/test_core.py b/tests/test_core.py index d442adb..94b6204 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: From 84ba285154fc1681be19249100df7ee9f6f0ab70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?deniz=20g=C3=B6k=C3=A7in?= <33603535+dgokcin@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:51:00 +0200 Subject: [PATCH 4/5] docs(cc_notifier): clarify switched-away scenarios and focus command args - document three switched-away detection scenarios in send_local_notification_if_needed - add iterm2_session_id parameter description to create_focus_command --- cc_notifier.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cc_notifier.py b/cc_notifier.py index 590dd45..92a1351 100644 --- a/cc_notifier.py +++ b/cc_notifier.py @@ -254,7 +254,13 @@ def send_local_notification_if_needed( 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): @@ -595,8 +601,12 @@ def create_focus_command( 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 From 8178ebfa7f9a400e95fd2f7bc80f4e2fa081703a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?deniz=20g=C3=B6k=C3=A7in?= <33603535+dgokcin@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:51:05 +0200 Subject: [PATCH 5/5] test(core): add iterm2 session preservation and restore assertions - add test_dedup_preserves_iterm2_session_id to verify timestamp rewrites keep session id - enhance tab notification test to verify osascript restore in execute chain - update tests.context.md with new test count (71 total, 62 core) --- tests/test_core.py | 20 ++++++++++++++++++++ tests/tests.context.md | 7 ++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 94b6204..a66ed58 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -524,6 +524,12 @@ def test_notify_sent_when_same_iterm2_window_but_different_tab(self, tmp_path): ] 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"} @@ -585,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/tests.context.md b/tests/tests.context.md index 4e2e12b..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**: **70 total tests** (61 core + 9 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,7 +27,7 @@ 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 (14 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 @@ -40,6 +40,7 @@ Associated with: all tests in the codebase - `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