From e9587bf0455fddc1cb6dd980efea72bd36be56fc Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 15:42:31 -0800 Subject: [PATCH] Smart attach: link window when inside tmux When 'gt X attach' is run from inside a tmux session, link the target session's window as a new tab instead of switching sessions entirely. Use C-b n/p to navigate between tabs. Outside tmux: unchanged behavior (full attach) Inside tmux: links window as tab for convenient multi-session viewing - Add tmux.LinkWindow() and tmux.IsInsideTmux() - Update attachToTmuxSession() with smart detection - Update mayor, deacon, crew, refinery attach commands --- internal/cmd/crew_helpers.go | 16 +++++++++++++++- internal/cmd/deacon.go | 10 ++-------- internal/cmd/mayor.go | 23 ++--------------------- internal/tmux/tmux.go | 16 ++++++++++++++++ 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/internal/cmd/crew_helpers.go b/internal/cmd/crew_helpers.go index f018abbf..1e727401 100644 --- a/internal/cmd/crew_helpers.go +++ b/internal/cmd/crew_helpers.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) @@ -168,8 +169,21 @@ func isInTmuxSession(targetSession string) bool { return currentSession == targetSession } -// attachToTmuxSession attaches to a tmux session with proper TTY forwarding. +// attachToTmuxSession attaches to a tmux session with smart behavior: +// - If already inside tmux: links the session as a new tab (use C-b n/p to switch) +// - If outside tmux: attaches normally (takes over terminal) func attachToTmuxSession(sessionID string) error { + // If already inside tmux, link the window as a tab instead of switching sessions + if tmux.IsInsideTmux() { + t := tmux.NewTmux() + if err := t.LinkWindow(sessionID, 0); err != nil { + return fmt.Errorf("linking window: %w", err) + } + fmt.Printf("Linked %s as a new tab. Use C-b n to switch to it.\n", sessionID) + return nil + } + + // Outside tmux: attach normally tmuxPath, err := exec.LookPath("tmux") if err != nil { return fmt.Errorf("tmux not found: %w", err) diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 3e71eafa..55404346 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -3,7 +3,6 @@ package cmd import ( "errors" "fmt" - "os/exec" "strings" "time" @@ -199,13 +198,8 @@ func runDeaconAttach(cmd *cobra.Command, args []string) error { } // Session uses a respawn loop, so Claude restarts automatically if it exits - // Use exec to replace current process with tmux attach - tmuxPath, err := exec.LookPath("tmux") - if err != nil { - return fmt.Errorf("tmux not found: %w", err) - } - - return execCommand(tmuxPath, "attach-session", "-t", DeaconSessionName) + // Use shared attach helper (smart: links if inside tmux, attaches if outside) + return attachToTmuxSession(DeaconSessionName) } func runDeaconStatus(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index e842a9ea..c0cd9ed4 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -3,8 +3,6 @@ package cmd import ( "errors" "fmt" - "os" - "os/exec" "time" "github.com/spf13/cobra" @@ -179,25 +177,8 @@ func runMayorAttach(cmd *cobra.Command, args []string) error { } } - // Use exec to replace current process with tmux attach - tmuxPath, err := exec.LookPath("tmux") - if err != nil { - return fmt.Errorf("tmux not found: %w", err) - } - - return execCommand(tmuxPath, "attach-session", "-t", MayorSessionName) -} - -// execCommand replaces the current process with the given command. -// This is used for attaching to tmux sessions. -func execCommand(name string, args ...string) error { - // On Unix, we would use syscall.Exec to replace the process - // For portability, we use exec.Command and wait - cmd := exec.Command(name, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + // Use shared attach helper (smart: links if inside tmux, attaches if outside) + return attachToTmuxSession(MayorSessionName) } func runMayorStatus(cmd *cobra.Command, args []string) error { diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index ddd9c5b2..a8da307b 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -5,6 +5,7 @@ import ( "bytes" "errors" "fmt" + "os" "os/exec" "strings" "time" @@ -519,6 +520,21 @@ func (t *Tmux) ConfigureGasTownSession(session string, theme Theme, rig, worker, return nil } +// LinkWindow links a window from another session into the current session. +// This allows viewing another session's window as a tab without switching sessions. +// Useful when already inside tmux and want to see another session. +func (t *Tmux) LinkWindow(sourceSession string, windowIndex int) error { + source := fmt.Sprintf("%s:%d", sourceSession, windowIndex) + _, err := t.run("link-window", "-s", source) + return err +} + +// IsInsideTmux checks if the current process is running inside a tmux session. +// This is detected by the presence of the TMUX environment variable. +func IsInsideTmux() bool { + return os.Getenv("TMUX") != "" +} + // SetMailClickBinding configures left-click on status-right to show mail preview. // This creates a popup showing the first unread message when clicking the mail icon area. func (t *Tmux) SetMailClickBinding(session string) error {