Skip to content

Commit b76f8d9

Browse files
authored
Merge pull request #9 from dgokcin/iterm-tab-notifications
feat(iterm2): add tab-level notification and click-to-restore
2 parents 8b8b19d + 8178ebf commit b76f8d9

6 files changed

Lines changed: 270 additions & 27 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
**Smart Notifications for Claude Code on Desktop and Mobile**
44

5-
Click notifications to instantly restore your exact Claude Code window across macOS Spaces—not just the app, but your specific terminal or IDE window.
5+
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).
66

77
Also enables seamless 📱 mobile development via push notifications.
88

99
## Features
1010

11-
- **🎯 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.
11+
- **🎯 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.
1212
- **🧠 Intelligent Detection** - 💻 Desktop: notifies when you switch windows | 🌐 Remote: notifies when idle
1313
- **⚡ Fast & Async** - Runs in background, never blocks Claude Code
1414
- **📲 Push Notifications** - Desktop: optional idle alerts | Remote: primary notification method (Pushover)
@@ -107,8 +107,9 @@ Add to `~/.claude/settings.json`:
107107
2. **Task Completion** → Compares current window vs original window
108108
3. **Smart Notification:**
109109
- 🪟 **Switched windows?** → Local notification with click-to-focus
110+
- 🗂️ **Switched iTerm2 tabs in same window?** → Local notification with tab-aware click-to-focus
110111
- 💤 **Idle at desk?** → Optional push notification via Pushover
111-
4. **Click Notification** → Hammerspoon instantly restores your exact window across Spaces
112+
4. **Click Notification** → Hammerspoon restores your exact window across Spaces; iTerm2 sessions also restore the original tab
112113

113114
### 🌐 Remote Mode (SSH)
114115

cc_notifier.context.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Primarily a high-level architectural reference, not a detailed implementation gu
1212

1313
## Key Components
1414

15-
- **Session Files**: `/tmp/cc_notifier/{session_id}` containing window ID, app path, timestamp, and tmux session ID
15+
- **Session Files**: `/tmp/cc_notifier/{session_id}` containing window ID, app path, timestamp, tmux session ID, and optional iTerm2 session ID
1616
- **Window Management**: Hammerspoon CLI for cross-space window focusing
1717
- **Local Notifications**: terminal-notifier with `-execute` parameter for click actions
1818
- **Push Notifications**: Pushover API integration
@@ -27,10 +27,11 @@ Flows are in the order they are executed, and are performed synchronously, unles
2727
**Flow**:
2828
1. Parse session data from stdin JSON
2929
2. **Desktop Mode**: Get focused window ID via Hammerspoon CLI (`hs.window.focusedWindow()`)
30+
- If focused app is iTerm2: capture focused iTerm2 session ID via AppleScript for tab-level tracking
3031
**Remote Mode**: Use placeholder "REMOTE" (auto-detected via SSH environment variables)
3132
**Hammerspoon Missing**: Falls back to "UNAVAILABLE" placeholder (graceful degradation)
3233
3. Capture tmux session ID via `tmux display-message -p '#{session_id}'` (both modes, None if not in tmux)
33-
4. Save window ID, app path, timestamp, and tmux session ID to `/tmp/cc_notifier/{session_id}`
34+
4. Save window ID, app path, timestamp, tmux session ID, and optional iTerm2 session ID to `/tmp/cc_notifier/{session_id}`
3435
5. Exit immediately
3536

3637
### `cc-notifier notify`
@@ -48,9 +49,11 @@ Flows are in the order they are executed, and are performed synchronously, unles
4849
- If window ID is available:
4950
- Get current focused window ID via Hammerspoon CLI
5051
- Compare original vs current window ID
52+
- Same iTerm2 window + different iTerm2 session ID: User switched tabs, send notification
5153
- Same window + tmux session detached: User switched tmux sessions, send notification
5254
- Same window + tmux attached or no tmux: Don't send local notification, continue to push check
5355
- Different window: Send local notification via terminal-notifier with click-to-focus
56+
- Click-to-focus restores original window; for iTerm2 sessions, it also restores the original tab/session
5457
- Local notification failures are caught so push notifications still fire
5558
- Update session timestamp
5659
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
105108
<window_id>
106109
<app_path>
107110
<unix_timestamp>
108-
<tmux_session_id> (optional, empty string if not in tmux)
111+
<tmux_session_id> (optional, empty string if not in tmux)
112+
<iterm2_session_id> (optional, only for iTerm2 desktop sessions)
109113
```
110-
- 4th line is optional for backward compatibility — old 3-line session files still work
114+
- 4th/5th lines are optional for backward compatibility — old 3-line and 4-line session files still work
111115

112116
**Log Files**
113117
- Stored in `~/.cc-notifier/cc-notifier.log`

cc_notifier.py

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,22 @@ def main() -> None:
101101
def cmd_init() -> None:
102102
"""Initialize session by capturing focused window ID and app path."""
103103
hook_data = HookData.from_stdin()
104+
iterm2_session_id = ""
104105
if is_remote_session():
105106
window_id, app_path = "REMOTE", "REMOTE"
106107
debug_log("Remote session detected, skipping window capture")
107108
else:
108109
try:
109110
window_id, app_path = get_focused_window_id()
111+
if is_iterm2_app(app_path):
112+
iterm2_session_id = get_iterm2_focused_session_id()
110113
except (RuntimeError, OSError) as e:
111114
window_id, app_path = "UNAVAILABLE", "UNAVAILABLE"
112115
debug_log(f"Window capture failed, continuing without: {e}")
113116
tmux_session_id = get_tmux_session_id() or ""
114-
save_window_id(hook_data.session_id, window_id, app_path, tmux_session_id)
117+
save_window_id(
118+
hook_data.session_id, window_id, app_path, tmux_session_id, iterm2_session_id
119+
)
115120

116121

117122
@handle_command_errors("notify")
@@ -128,6 +133,7 @@ def cmd_notify() -> None:
128133
original_window_id = lines[0]
129134
app_path = lines[1]
130135
tmux_session_id = lines[3] if len(lines) > 3 else ""
136+
iterm2_session_id = lines[4] if len(lines) > 4 else ""
131137

132138
# Set global app path for error handling
133139
_CURRENT_APP_PATH = app_path
@@ -136,7 +142,11 @@ def cmd_notify() -> None:
136142
if not is_remote_session():
137143
try:
138144
send_local_notification_if_needed(
139-
hook_data, original_window_id, tmux_session_id
145+
hook_data,
146+
original_window_id,
147+
app_path,
148+
tmux_session_id,
149+
iterm2_session_id,
140150
)
141151
except (RuntimeError, OSError) as e:
142152
log_error("Local notification failed, continuing to push", e)
@@ -223,9 +233,14 @@ def check_deduplication(session_file: Path) -> bool:
223233
< NOTIFICATION_DEDUPLICATION_THRESHOLD_SECONDS
224234
):
225235
return True
236+
app_path = lines[1] if len(lines) > 1 else ""
226237
tmux_id = lines[3] if len(lines) > 3 else ""
238+
iterm2_session_id = lines[4] if len(lines) > 4 else ""
227239
f.seek(0)
228-
f.write(f"{lines[0]}\n{lines[1]}\n{time.time()}\n{tmux_id}")
240+
updated_content = f"{lines[0]}\n{app_path}\n{time.time()}\n{tmux_id}"
241+
if iterm2_session_id:
242+
updated_content += f"\n{iterm2_session_id}"
243+
f.write(updated_content)
229244
f.truncate()
230245
return False
231246
except BlockingIOError:
@@ -235,9 +250,17 @@ def check_deduplication(session_file: Path) -> bool:
235250
def send_local_notification_if_needed(
236251
hook_data: HookData,
237252
original_window_id: str,
253+
app_path: str,
238254
tmux_session_id: str = "",
255+
iterm2_session_id: str = "",
239256
) -> None:
240-
"""Send local notification if user switched away from original window."""
257+
"""Send local notification if user switched away from original window.
258+
259+
Detects three "switched away" scenarios:
260+
- User switched to a different window entirely
261+
- User switched iTerm2 tabs within the same window
262+
- User detached/switched tmux sessions within the same window
263+
"""
241264
# Without Hammerspoon, check tmux session before sending
242265
if original_window_id == "UNAVAILABLE":
243266
if tmux_session_id and is_tmux_session_attached(tmux_session_id):
@@ -250,9 +273,27 @@ def send_local_notification_if_needed(
250273
send_notification(title=title, subtitle=subtitle, message=message)
251274
return
252275

253-
current_window_id, _ = get_focused_window_id()
276+
current_window_id, current_app_path = get_focused_window_id()
277+
iterm2_tab_switched = False
278+
279+
if (
280+
original_window_id == current_window_id
281+
and iterm2_session_id
282+
and is_iterm2_app(app_path)
283+
and is_iterm2_app(current_app_path)
284+
):
285+
current_iterm2_session_id = get_iterm2_focused_session_id()
286+
if current_iterm2_session_id and current_iterm2_session_id != iterm2_session_id:
287+
iterm2_tab_switched = True
288+
debug_log(
289+
"Same iTerm2 window but different session ID - user switched tabs"
290+
)
291+
elif not current_iterm2_session_id:
292+
debug_log(
293+
"Unable to read current iTerm2 session ID - falling back to window/tmux detection"
294+
)
254295

255-
if original_window_id == current_window_id:
296+
if original_window_id == current_window_id and not iterm2_tab_switched:
256297
# Same window, but check if user switched tmux sessions within it
257298
if tmux_session_id and not is_tmux_session_attached(tmux_session_id):
258299
debug_log(
@@ -274,6 +315,7 @@ def send_local_notification_if_needed(
274315
subtitle=subtitle,
275316
message=message,
276317
focus_window_id=original_window_id,
318+
focus_iterm2_session_id=iterm2_session_id if is_iterm2_app(app_path) else None,
277319
)
278320

279321

@@ -282,13 +324,17 @@ def save_window_id(
282324
window_id: str,
283325
app_path: str,
284326
tmux_session_id: str = "",
327+
iterm2_session_id: str = "",
285328
) -> None:
286-
"""Save window ID, app path, and tmux session ID to session file."""
329+
"""Save window ID, app path, tmux, and optional iTerm2 session ID."""
287330
SESSION_DIR.mkdir(exist_ok=True)
288331
session_file = SESSION_DIR / session_id
289-
session_file.write_text(f"{window_id}\n{app_path}\n0\n{tmux_session_id}")
332+
content = f"{window_id}\n{app_path}\n0\n{tmux_session_id}"
333+
if iterm2_session_id:
334+
content += f"\n{iterm2_session_id}"
335+
session_file.write_text(content)
290336
debug_log(
291-
f"Session initialized: window_id={window_id}, app_path={app_path}, tmux={tmux_session_id}, session_file={session_file}"
337+
f"Session initialized: window_id={window_id}, app_path={app_path}, tmux={tmux_session_id}, iterm2_session={iterm2_session_id}, session_file={session_file}"
292338
)
293339

294340

@@ -495,7 +541,57 @@ def get_focused_window_id() -> tuple[str, str]:
495541
) from e
496542

497543

498-
def create_focus_command(window_id: str) -> list[str]:
544+
def is_iterm2_app(app_path: str) -> bool:
545+
"""Return True when app path identifies iTerm2."""
546+
return app_path.endswith("/iTerm.app") or app_path.endswith("/iTerm2.app")
547+
548+
549+
def get_iterm2_focused_session_id() -> str:
550+
"""Get iTerm2 focused session ID, or empty string when unavailable."""
551+
script_lines = [
552+
'tell application "iTerm2"',
553+
'if not running then return ""',
554+
"try",
555+
"return id of current session of current window as text",
556+
"on error",
557+
'return ""',
558+
"end try",
559+
"end tell",
560+
]
561+
cmd = ["osascript"]
562+
for line in script_lines:
563+
cmd.extend(["-e", line])
564+
565+
try:
566+
return run_command(cmd, timeout=5)
567+
except (RuntimeError, subprocess.TimeoutExpired):
568+
return ""
569+
570+
571+
def _build_iterm2_restore_script(iterm2_session_id: str) -> str:
572+
"""Build AppleScript that focuses iTerm2 on a specific session ID."""
573+
escaped_session_id = iterm2_session_id.replace("\\", "\\\\").replace('"', '\\"')
574+
return f"""tell application "iTerm2"
575+
if not running then return
576+
repeat with w in windows
577+
repeat with t in tabs of w
578+
repeat with s in sessions of t
579+
if (id of s as text) is "{escaped_session_id}" then
580+
tell w to select
581+
tell t to select
582+
tell s to select
583+
activate
584+
return
585+
end if
586+
end repeat
587+
end repeat
588+
end repeat
589+
end tell"""
590+
591+
592+
def create_focus_command(
593+
window_id: str, iterm2_session_id: Optional[str] = None
594+
) -> list[str]:
499595
"""
500596
Create the Hammerspoon focus command for cross-space window focusing.
501597
@@ -505,8 +601,12 @@ def create_focus_command(window_id: str) -> list[str]:
505601
506602
If the window cannot be found or focused, shows an error notification.
507603
604+
When iterm2_session_id is provided, chains an AppleScript command after
605+
the Hammerspoon focus to restore the specific iTerm2 tab/session.
606+
508607
Args:
509608
window_id: The window ID to focus
609+
iterm2_session_id: Optional iTerm2 session ID for tab restoration
510610
511611
Returns:
512612
List of command arguments for subprocess execution
@@ -525,7 +625,16 @@ def create_focus_command(window_id: str) -> list[str]:
525625
end
526626
end
527627
require('hs.notify').new({{title="cc-notifier", informativeText="Could not restore window focus. Try reopening your terminal or IDE.", soundName="Basso"}}):send()"""
528-
return [HAMMERSPOON_CLI, "-c", focus_script]
628+
if not iterm2_session_id:
629+
return [HAMMERSPOON_CLI, "-c", focus_script]
630+
631+
hs_cmd = [HAMMERSPOON_CLI, "-c", focus_script]
632+
osascript_cmd = ["osascript", "-e", _build_iterm2_restore_script(iterm2_session_id)]
633+
combined = (
634+
f"{' '.join(shlex.quote(arg) for arg in hs_cmd)}; "
635+
f"{' '.join(shlex.quote(arg) for arg in osascript_cmd)}"
636+
)
637+
return ["/bin/sh", "-c", combined]
529638

530639

531640
# ============================================================================
@@ -627,7 +736,11 @@ def create_notification_data(
627736

628737

629738
def send_notification(
630-
title: str, subtitle: str, message: str, focus_window_id: Optional[str] = None
739+
title: str,
740+
subtitle: str,
741+
message: str,
742+
focus_window_id: Optional[str] = None,
743+
focus_iterm2_session_id: Optional[str] = None,
631744
) -> None:
632745
"""Send a macOS notification with optional click-to-focus functionality."""
633746
cmd = [
@@ -645,7 +758,7 @@ def send_notification(
645758

646759
# Add click-to-focus functionality if window ID provided
647760
if focus_window_id:
648-
focus_cmd = create_focus_command(focus_window_id)
761+
focus_cmd = create_focus_command(focus_window_id, focus_iterm2_session_id)
649762
execute_cmd = " ".join(shlex.quote(arg) for arg in focus_cmd)
650763
cmd.extend(["-execute", execute_cmd])
651764

0 commit comments

Comments
 (0)