From 56d365460cb80e6f7de9ef7b925dfc2bff7e4b48 Mon Sep 17 00:00:00 2001 From: Cadu Date: Fri, 20 Mar 2026 10:12:51 -0300 Subject: [PATCH 1/9] Use grouped tmux sessions for independent window navigation (#20) Replace `tmux attach-session` with `tmux new-session -t` when reattaching to an existing session. This creates a grouped session that shares windows but allows each terminal to independently select the active window. The `destroy-unattached` option ensures grouped sessions are cleaned up when the terminal disconnects. --- forestui/cli.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/forestui/cli.py b/forestui/cli.py index ff891ad..0721e7f 100644 --- a/forestui/cli.py +++ b/forestui/cli.py @@ -111,7 +111,19 @@ def ensure_tmux( ], ) - os.execvp("tmux", ["tmux", "attach-session", "-t", session_name]) + os.execvp( + "tmux", + [ + "tmux", + "new-session", + "-t", + session_name, + ";", + "set-option", + "destroy-unattached", + "on", + ], + ) else: # No session: create new one with forestui as initial command os.execvp("tmux", ["tmux", "new-session", "-s", session_name, forestui_cmd]) From 1afef203ec707fb0216e50f3a23ddcc1cfd1031f Mon Sep 17 00:00:00 2001 From: Cadu Date: Thu, 26 Mar 2026 23:44:38 -0300 Subject: [PATCH 2/9] Fix grouped tmux session creation by splitting into separate commands The semicolon command separator in os.execvp doesn't work reliably with tmux. Instead, create the grouped session detached with a PID-based unique name, set destroy-unattached on it, then attach. --- forestui/cli.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/forestui/cli.py b/forestui/cli.py index 0721e7f..ac969a8 100644 --- a/forestui/cli.py +++ b/forestui/cli.py @@ -111,19 +111,19 @@ def ensure_tmux( ], ) - os.execvp( - "tmux", - [ - "tmux", - "new-session", - "-t", - session_name, - ";", - "set-option", - "destroy-unattached", - "on", - ], + # 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()}" + subprocess.run( + ["tmux", "new-session", "-d", "-s", grouped_name, "-t", session_name], + capture_output=True, + ) + subprocess.run( + ["tmux", "set-option", "-t", grouped_name, "destroy-unattached", "on"], + 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]) From f99b5627ed25b84e31aa1a81e48b5e554d09f9d8 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 28 Mar 2026 14:33:15 -0300 Subject: [PATCH 3/9] Use tmux hook to defer destroy-unattached until after client attaches Setting destroy-unattached on a detached session causes tmux to destroy it immediately. Use a client-attached hook instead so the option is set only after the client connects, avoiding the race condition. --- forestui/cli.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/forestui/cli.py b/forestui/cli.py index ac969a8..42dd513 100644 --- a/forestui/cli.py +++ b/forestui/cli.py @@ -114,13 +114,22 @@ def ensure_tmux( # 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. + # We use a hook to set destroy-unattached AFTER the client attaches, + # because setting it on a detached session destroys it immediately. grouped_name = f"{session_name}-{os.getpid()}" subprocess.run( ["tmux", "new-session", "-d", "-s", grouped_name, "-t", session_name], capture_output=True, ) subprocess.run( - ["tmux", "set-option", "-t", grouped_name, "destroy-unattached", "on"], + [ + "tmux", + "set-hook", + "-t", + grouped_name, + "client-attached", + "set-option destroy-unattached on", + ], capture_output=True, ) os.execvp("tmux", ["tmux", "attach-session", "-t", grouped_name]) From f9019eafc0538b35e4ba8616c9f0e29403782f18 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 28 Mar 2026 15:03:04 -0300 Subject: [PATCH 4/9] Fix TmuxService.session to resolve the actual current session Use tmux display-message to get the session ID of the process's own session instead of picking the first attached one. In grouped session setups, multiple sessions are attached simultaneously, and the old approach would always find the original session, causing window operations (open editor, terminal, claude) to switch the active window in the wrong terminal. --- forestui/services/tmux.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/forestui/services/tmux.py b/forestui/services/tmux.py index 9969a9f..784e8be 100644 --- a/forestui/services/tmux.py +++ b/forestui/services/tmux.py @@ -60,19 +60,26 @@ def session(self) -> Session | 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) + # Use display-message to get the actual session we're running in. + # This is critical for grouped sessions where multiple sessions + # are attached but we need the one THIS process belongs to. + result = self.server.cmd("display-message", "-p", "#{session_id}") + current_id = result.stdout[0].strip() if result.stdout else "" + if current_id: + for sess in self.server.sessions: + if sess.session_id == current_id: + self._session = sess + break + # Fallback: find any attached session + if self._session is None: 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] + # 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 From 0a7c0f03f6772fc2c63cd93b39d02703df69a5c9 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 28 Mar 2026 15:10:05 -0300 Subject: [PATCH 5/9] Fix TmuxService.session to resolve the actual current session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forestui process runs in the original session's pane, so display-message always returned the original session ID regardless of which grouped session the user is viewing from. Instead, use list-clients sorted by client_activity to find the most recently active client's session — that's the terminal the user just interacted with. Remove session caching since the active client can change between grouped sessions at any time. --- forestui/services/tmux.py | 63 +++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/forestui/services/tmux.py b/forestui/services/tmux.py index 784e8be..c2ca70b 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,34 +54,46 @@ 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: - # Use display-message to get the actual session we're running in. - # This is critical for grouped sessions where multiple sessions - # are attached but we need the one THIS process belongs to. - result = self.server.cmd("display-message", "-p", "#{session_id}") - current_id = result.stdout[0].strip() if result.stdout else "" - if current_id: - for sess in self.server.sessions: - if sess.session_id == current_id: - self._session = sess - break - # Fallback: find any attached session - if self._session is None: + try: + # Find the most recently active client's session. + # 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}" + ) + if result.stdout: + best_id: str | None = None + best_time = -1 + for line in result.stdout: + parts = line.strip().split(" ", 1) + if len(parts) == 2: + activity = int(parts[0]) + if activity > best_time: + best_time = activity + best_id = parts[1] + 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: From 68ff4027005811d46ea131347626192f94d8c14c Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 28 Mar 2026 15:20:43 -0300 Subject: [PATCH 6/9] Hide PID-suffixed session name in tmux status bar for grouped sessions Override status-left on grouped sessions to show the base session name (e.g. forestui-forest) instead of the internal PID-suffixed name (e.g. forestui-forest-23116). The grouped session is an implementation detail that should be transparent to the user. --- forestui/cli.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/forestui/cli.py b/forestui/cli.py index 42dd513..aad79a0 100644 --- a/forestui/cli.py +++ b/forestui/cli.py @@ -132,6 +132,28 @@ def ensure_tmux( ], capture_output=True, ) + # Override status-left so the grouped session shows the base session + # name instead of the PID-suffixed internal name. + status_result = subprocess.run( + ["tmux", "show-options", "-g", "status-left"], + capture_output=True, + text=True, + ) + if status_result.returncode == 0 and status_result.stdout.strip(): + # Replace #S (session name variable) with the literal base name + status_left = status_result.stdout.strip().removeprefix("status-left ") + # Strip outer quotes if present + if ( + len(status_left) >= 2 + and status_left[0] == '"' + and status_left[-1] == '"' + ): + status_left = status_left[1:-1] + status_left = status_left.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 From 6690550246fc7f5aace34fbf37221e7f41e081d4 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 28 Mar 2026 15:51:36 -0300 Subject: [PATCH 7/9] Address PR review: fix injection, side effects, and session scoping 1. Fix shell injection via unquoted forest_path (shlex.quote) 2. Replace select-window existence check with read-only list-windows to avoid switching the active window in the original session 3. Scope list-clients to our session_group so unrelated tmux sessions don't cause forestui to resolve to the wrong session 4. Use destroy-unattached keep-last instead of on, preventing the last session in the group from being destroyed 5. Use show-options -gv for status-left to get just the value without option name prefix or quoting 6. Add error handling for grouped session creation with fallback to direct attach --- forestui/cli.py | 41 +++++++++++++++++++-------------------- forestui/services/tmux.py | 21 +++++++++++++++----- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/forestui/cli.py b/forestui/cli.py index aad79a0..6c454a7 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,13 @@ 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, ) - forestui_window_exists = result.returncode == 0 + forestui_window_exists = "forestui" in (list_result.stdout or "").splitlines() if not forestui_window_exists: # forestui was killed but session remains - create new window @@ -114,13 +116,18 @@ def ensure_tmux( # 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. - # We use a hook to set destroy-unattached AFTER the client attaches, - # because setting it on a detached session destroys it immediately. grouped_name = f"{session_name}-{os.getpid()}" - subprocess.run( + 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", @@ -128,28 +135,20 @@ def ensure_tmux( "-t", grouped_name, "client-attached", - "set-option destroy-unattached on", + "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. + # 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", "-g", "status-left"], + ["tmux", "show-options", "-gv", "status-left"], capture_output=True, text=True, ) if status_result.returncode == 0 and status_result.stdout.strip(): - # Replace #S (session name variable) with the literal base name - status_left = status_result.stdout.strip().removeprefix("status-left ") - # Strip outer quotes if present - if ( - len(status_left) >= 2 - and status_left[0] == '"' - and status_left[-1] == '"' - ): - status_left = status_left[1:-1] - status_left = status_left.replace("#S", session_name) + status_left = status_result.stdout.strip().replace("#S", session_name) subprocess.run( ["tmux", "set-option", "-t", grouped_name, "status-left", status_left], capture_output=True, diff --git a/forestui/services/tmux.py b/forestui/services/tmux.py index c2ca70b..17ea528 100644 --- a/forestui/services/tmux.py +++ b/forestui/services/tmux.py @@ -62,23 +62,34 @@ def session(self) -> Session | None: if not self.is_inside_tmux or self.server is None: return None try: - # Find the most recently active client's session. + # 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}" + "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(" ", 1) - if len(parts) == 2: + 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 = parts[1] + best_id = session_id if best_id: for sess in self.server.sessions: if sess.session_id == best_id: From 0001d66f4091e88025b5b5e960f8e1e35b00db4e Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 28 Mar 2026 17:14:51 -0300 Subject: [PATCH 8/9] Fix forestui window detection for dev mode window names The existence check used exact match on "forestui", missing dev mode windows named "forestui-dev-HHMM". This caused a second terminal to spawn a duplicate forestui process instead of just attaching. --- forestui/cli.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/forestui/cli.py b/forestui/cli.py index 6c454a7..ce6e76f 100644 --- a/forestui/cli.py +++ b/forestui/cli.py @@ -97,7 +97,11 @@ def ensure_tmux( capture_output=True, text=True, ) - forestui_window_exists = "forestui" in (list_result.stdout or "").splitlines() + window_names = (list_result.stdout or "").splitlines() + forestui_window_exists = any( + name == "forestui" or name.startswith("forestui-dev-") + for name in window_names + ) if not forestui_window_exists: # forestui was killed but session remains - create new window From d793a97bad4f59aca6b59a34c69bb209c1d29571 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 28 Mar 2026 17:20:23 -0300 Subject: [PATCH 9/9] Preserve trailing space in status-left template for grouped sessions strip() was removing a trailing space that's part of the tmux status-left template, causing the separator between the session name and the first window tab to disappear. Use rstrip('\n') to only remove the subprocess newline. --- forestui/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/forestui/cli.py b/forestui/cli.py index ce6e76f..dd16bc0 100644 --- a/forestui/cli.py +++ b/forestui/cli.py @@ -151,8 +151,9 @@ def ensure_tmux( capture_output=True, text=True, ) - if status_result.returncode == 0 and status_result.stdout.strip(): - status_left = status_result.stdout.strip().replace("#S", session_name) + 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,