@@ -101,17 +101,22 @@ def main() -> None:
101101def 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:
235250def 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 } \n 0\n { tmux_session_id } " )
332+ content = f"{ window_id } \n { app_path } \n 0\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
526626end
527627require('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
629738def 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