diff --git a/forestui/cli.py b/forestui/cli.py index ff891ad..dd16bc0 100644 --- a/forestui/cli.py +++ b/forestui/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shlex import shutil import sys from pathlib import Path @@ -78,7 +79,7 @@ def ensure_tmux( if dev_mode: forestui_cmd += " --dev" if forest_path: - forestui_cmd += f" {forest_path}" + forestui_cmd += f" {shlex.quote(forest_path)}" # Check if session already exists import subprocess @@ -90,12 +91,17 @@ def ensure_tmux( session_exists = result.returncode == 0 if session_exists: - # Session exists: check if forestui window is already running - result = subprocess.run( - ["tmux", "select-window", "-t", f"{session_name}:forestui"], + # Check if forestui window exists (read-only, no side effects) + list_result = subprocess.run( + ["tmux", "list-windows", "-t", session_name, "-F", "#{window_name}"], capture_output=True, + text=True, + ) + window_names = (list_result.stdout or "").splitlines() + forestui_window_exists = any( + name == "forestui" or name.startswith("forestui-dev-") + for name in window_names ) - forestui_window_exists = result.returncode == 0 if not forestui_window_exists: # forestui was killed but session remains - create new window @@ -111,7 +117,48 @@ def ensure_tmux( ], ) - os.execvp("tmux", ["tmux", "attach-session", "-t", session_name]) + # Create a grouped session for independent window navigation. + # Each terminal gets its own session linked to the same window group, + # so switching windows in one terminal doesn't affect the other. + grouped_name = f"{session_name}-{os.getpid()}" + grouped_result = subprocess.run( + ["tmux", "new-session", "-d", "-s", grouped_name, "-t", session_name], + capture_output=True, + ) + if grouped_result.returncode != 0: + # Grouped session creation failed, fall back to direct attach + os.execvp("tmux", ["tmux", "attach-session", "-t", session_name]) + + # Use a hook to set destroy-unattached AFTER the client attaches, + # because setting it on a detached session destroys it immediately. + # keep-last prevents the last session in the group from being destroyed. + subprocess.run( + [ + "tmux", + "set-hook", + "-t", + grouped_name, + "client-attached", + "set-option destroy-unattached keep-last", + ], + capture_output=True, + ) + # Override status-left so the grouped session shows the base session + # name instead of the PID-suffixed internal name. Uses -gv to get + # just the value without the option name prefix or quoting. + status_result = subprocess.run( + ["tmux", "show-options", "-gv", "status-left"], + capture_output=True, + text=True, + ) + if status_result.returncode == 0 and status_result.stdout.rstrip("\n"): + # Only strip the trailing newline, not spaces that are part of the template + status_left = status_result.stdout.rstrip("\n").replace("#S", session_name) + subprocess.run( + ["tmux", "set-option", "-t", grouped_name, "status-left", status_left], + capture_output=True, + ) + os.execvp("tmux", ["tmux", "attach-session", "-t", grouped_name]) else: # No session: create new one with forestui as initial command os.execvp("tmux", ["tmux", "new-session", "-s", session_name, forestui_cmd]) diff --git a/forestui/services/tmux.py b/forestui/services/tmux.py index 9969a9f..17ea528 100644 --- a/forestui/services/tmux.py +++ b/forestui/services/tmux.py @@ -29,7 +29,6 @@ class TmuxService: _instance: TmuxService | None = None _server: Server | None = None - _session: Session | None = None def __new__(cls) -> TmuxService: if cls._instance is None: @@ -55,27 +54,57 @@ def server(self) -> Server | None: @property def session(self) -> Session | None: - """Get the current tmux session.""" + """Get the session of the most recently active tmux client. + + Not cached because the active client can change between grouped + sessions — the user may be viewing forestui from any terminal. + """ if not self.is_inside_tmux or self.server is None: return None - if self._session is None: - try: - # Get session from TMUX environment variable - # Format: /socket/path,pid,window_index - tmux_env = os.environ.get("TMUX", "") - if tmux_env: - # Find the attached session (session_attached is a count > 0) + try: + # Get our session group so we only consider clients attached to + # sessions in the same group — not unrelated tmux sessions. + group_result = self.server.cmd("display-message", "-p", "#{session_group}") + our_group = group_result.stdout[0].strip() if group_result.stdout else "" + + # Find the most recently active client in our session group. + # In grouped sessions, multiple clients are attached to different + # sessions sharing the same windows. The user who just triggered + # an action is the most recently active client. + result = self.server.cmd( + "list-clients", + "-F", + "#{client_activity} #{session_id} #{session_group}", + ) + if result.stdout: + best_id: str | None = None + best_time = -1 + for line in result.stdout: + parts = line.strip().split(" ", 2) + if len(parts) == 3: + activity = int(parts[0]) + session_id = parts[1] + group = parts[2] + if our_group and group != our_group: + continue + if activity > best_time: + best_time = activity + best_id = session_id + if best_id: for sess in self.server.sessions: - attached = sess.session_attached - if attached and int(attached) > 0: - self._session = sess - break - # Fallback to first session if none found - if self._session is None and self.server.sessions: - self._session = self.server.sessions[0] - except (LibTmuxException, ValueError, TypeError): - return None - return self._session + if sess.session_id == best_id: + return sess + # Fallback: first attached session + for sess in self.server.sessions: + attached = sess.session_attached + if attached and int(attached) > 0: + return sess + # Fallback: first session + if self.server.sessions: + return self.server.sessions[0] + except (LibTmuxException, ValueError, TypeError): + return None + return None @property def current_window(self) -> Window | None: