Autolaunch tmux with custom layout under vscode

This post is originally published as a Github Gist last year at 2023-07-22. I re-posted it with modifications here simply because I feel like documenting it here.

As a part of our company workflow, we requires a tmux setup that would automatically open specific file locations with a certain layout when vscode terminal is open.

Let’s implement them.

Issues

Our company requires us work with several project directories. For simplification, we assume that each project directories have unique directory names. Below are example arbitrary directory path under which we will have to work on:

  • ~/App/core/ for backend repository
  • ~/App/fe/ for frontend repository
  • ~/App/deployment/ for deployment repository

We are working with our codes using vscode, and normally we access its built-in terminal to run various tools such as artisan, composer, npm, or even yarn under our project directories. Sometimes we want the session to persist, even when vscode fails. Even better, we should also be able to connect to the vscode terminal session from an external terminal emulator. This is usually done because we can get wider and cleaner view using a dedicated terminal emulator, rather than using built-in vscode terminal.

How do we satisfy those specific use cases?

Design Overview

Since we are working with multiple directories, and we want to have named sessions we can attach to from either vscode built-in terminal or a proper terminal emulator, we have to use some form of session persistence. There are plenty of tools that allow persistent terminal sessions, such as screen and tmux. In this article, we are going to use tmux, a modern terminal multiplexer that allows persistence of terminal sessions.

Since we also have multiple directories, ideally each of those directories have their own dedicated named sessions. For simplicity, we will just have to use the directory’s basename for their session names. Therefore we will have to have the following sessions: core, fe, and deployment.

We will have one default tmux configuration file that would actually show us useful information. Then for each of those sessions, we would have to craft their own config files depending on their use cases and specific requirements. Since the configuration has to work wherever we decided to attach the sessions from, the sessions would have to be made whenever we run any terminal from the first time, in a detached mode. The config file would also take care of the working directory.

At last, we want to have the persistent session as default behavior in our vscode terminal. We don’t want to just fired up vscode, open its terminal, and forgetting to set up tmux, and then run an important program that requires persistence. Therefore, if we fire up the vscode terminal, we must also automatically be attached to a tmux session.

Pseudocode

To satisfy the requirements in both of our use case and design overview, we can actually simplify our tasks in just six steps.

  1. On startup, check if we’re already under tmux.
  2. If we are not under tmux, check if there are existing session with a specific name.
  3. If the specified named tmux session is not present, then we make a new detached named session.
  4. From within the created detached name session, we send keys to it to load its custom configuration files. The config files would also take care of their working directory.
  5. Finally, we check if we are running under vscode.
  6. If we are, then we will connect to a tmux session with the same name as our working vscode directory.

A sane default tmux configuration

tmux actually works out of the box, and can be used right away. However, the sane configuration can be very esoteric for the uninitiated. I have to shift through a lot of documentations, just to get a sane default configuration. I started from Archlinux Wiki, and then from Practical Tmux, and I managed to compile my current default configuration.

The gist of my current configuration is as follows:

Key Action
Ctrl+a tmux prefix
Ctrl+a , rename current window
Ctrl+j detach from current tmux session
Shift+left move to the left tmux window
Shift+right move to the right tmux window
Shift+down create a new tmux window
Alt+left swap current window to the left
Alt+right swap current window to the right

Keys separated by a plus sign (‘+’) must be pressed together, while the one separated with a space means perform the key on the left first, then release and press the next key in the sequence.

I also set a minimalistic but useful tmux status bar, that will show you the current session name, current session group, current tmux windows (that changes color depending on whether they are the active window or not), your username and machine name, and current machine time.

Allowing multiple clients sharing the same session

Per our requirements, we want the sessions to be accessible from both the vscode terminal AND any external terminal emulator. Even better, pair programming is actually possible by having a remote user accessing the local machine via ssh and access the same terminal session.

To better achieve that, we can spawn two sessions, with the second session is attached and synchronized to the first main session. Therefore, any new clients can attach to the first, be it a vscode terminal, a terminal emulator, or even a remote user via ssh. To prevent cluttering of ‘zombie’ sessions, that is client sessions that are no longer attached, we have to do clean up on the sessions that are connected to the main session.

There are many solutions to achieve the aforementioned specifications. First there is one from Practical Tmux article, and there are two alternative versions in Archlinux Wiki. The one in Archlinux Wiki is cleaner, and actually handles cleaning up of zombie sessions better than the one in Practical Tmux article. Both uses current datetime stamp for children sessions, and the alternative implementation from Archlinux Wiki uses a bash function stored in our ~/.bashrc file uses unix timestamp to differentiate parent session and child session.

However I am not satisfied with them for cosmetic reasons. Therefore I modified the tmx script implementation from both Practical Tmux article and Archlinux Wiki one, with one major difference: I use the substring of md5 hash of child session creation unix timestamp as the child session name. I do that because my tmux configuration shows both the session name and the session group name for brevity.

You can see my tmx implementation here.

To use the script, you have to add that script in your path and make it executable. It can easily be done by exporting your script directory to your path. For example in my case, to add ~/.local/bin in our $PATH only if it is not yet appended:

test -z "$(echo $PATH|grep "$HOME/\.local/bin")" \
    && PATH="$HOME/.local/bin:$PATH"

Then to make the script executable, you can do:

chmod +x ~/.local/bin/tmx

To make the changes persistent, add them to your .bashrc or .zshrc.

Therefore, with tmx script, we can create a new named session on demand, or connect to an existing named session if it is already present. It is useful since in the next part we will discuss about the creation of multiple detached named sessions, each with custom configuration files of the same name located under ~/.tmux.conf.d/sessions/. After they are automatically generated, we can just connect them from any terminal in our machine just by running tmx <session_name>. That including automated deployment of a tmux session from vscode at later stages of this article.

A convenience script to generate named sessions

Basically, step 1-4 of our pseudocode can be done in just a single convenience script. Here we will make a script called set_tmux and put it somewhere in our path. In my case, I put it under ~/.local/bin/

The content of the script is as follows (read the comments to see the workflow):

#!/bin/sh
# Sets named tmux sessions
# usage: set_tmux <session>
# Assumes ~/.tmux.conf.d/sessions/<session>.conf
# file exist

# Loads session $session, if $session is empty,
# then load a session with the same name as the
# first argument. If the first argument is empty,
# then $session name is 'main'
tmux_load_config(){
test -z "${session}" && session="$1"
test -z "${session}" && session="main"
local config_file="${HOME}/.tmux.conf.d/sessions/${session}.conf"
test -f "$config_file" && \
    tmux send-keys -t "${session}" \
        "tmux source-file ${config_file}" C-m
}

# If session $1 is not present, make one
configure_tmux(){
local session="$1"
if test -z "$TMUX" ; then
  tmux ls 2>/dev/null | grep -q "^$session":
  if test $? -gt 0 ; then
    tmux new-session -d -s "$session" && \
    tmux_load_config
  fi
fi
}
# Loop through session names and generates named
# sessions
for x in "$@" ;
do
  configure_tmux "$x"
done

The script is made with the assumption that we have more than one working directories. For example, if we need one for each of ~/App/core, ~/App/fe (front-end directory), and ~/App/deployment, then we can just have to create three configuration files under ~/.tmux.conf.d/sessions/:

  • core.conf
  • fe.conf
  • deployment.conf

We will discuss the tmux config files later in this article.

The for-loop section basically passess all arguments as session names. Then for each of them, it would run configure_tmux() by taking the session name as the argument. The configure_tmux() function would check with tmux ls command and piping the output to grep to determine whether the session of that name is already run or not. If it is not run already, only then would the function launches a new session of that name. It would then run tmux_load_config that basically checks if the config file for that particular session name is present or not. If it is present, then the config file would be loaded, otherwise it would be ignored and only default tmux configuration is loaded.

Then in our shell configurations (either ~/.bashrc, ~/.zshrc, or both), we just have to set the following lines:

set_tmux core fe deployment

Note that you must ensure that set_tmux is within your path. See the previous section that discusses tmx script on how to add this file to your path and make it executable.

Custom tmux configuration files

In this article we assume that you put your session configuration files under ~/.tmux.conf.d/sessions/. For the purpose of this article, we assume that the session name is the same as the configuration file name. For example, tmux session core would have a configuration file under ~/.tmux.conf.d/sessions/core.conf.

This article also assumes that the named tmux sessions are created with set_tmux script called from our ~/.bashrc or ~/.zshrc as described in the previous section. This is important, because set_tmux actually checks whether configuration file for that it creates is present in ~/.tmux.conf.d/sessions/<session>.conf. If it is present, then the script will load the configuration file. Therefore the config file will only be run once during the creation of those detached sessions.

Since most of our desired configurations are already set in our default tmux configuration (mine is located at ~/.config/tmux/tmux.conf), we would only need custom configurations for our own projects. In my case it is usually automated creation of workspace layout. It can be of windows, or to autorun certain commands during tmux session startup.

The simplest use case might be to automatically open two named windows in our tmux session: git and npm. To do that, we basically have to rename the first tmux window to git, and then we create a new named window npm. Here’s a simple configuration file that implements that:

# File ~/.tmux.conf.d/sessions/fe.conf
# Rename window :0
rename-window -t :0 'git'

# Make a new named window
new-window -t :1 -n 'npm'

# Select first window
selectw -t 0

However the working directory would still be the working directory of the spawned terminal. If the first terminal we open is the one in vscode, it is very likely that its working directory is the project directory, which is exactly what we want to do. However if you open another terminal emulator, that might not be the case. Instead it is likely that the working directory is from your home directory ($HOME or ~/).

We can actually circumvent that by creating new named windows with a specified working directory with the following command under the config file:

new-window -t xadf:1 -n 'git' -c ~/Documents/xadf

However when we make a new tmux session, there will always be one default window, usually <session_name>:0 (eg. main:0 or simply :0). The command above would only affect the working directory of new tmux windows. If we want to change the current working directory of the first window, or any tmux windows if that matters, is simply by sending key sequences to that specific tmux window:

# Target is a named tmux session 'xadf'

# Change directory to ~/Documents/xadf, enter,
# and clear the screen
send-keys -t xadf:0 'cd ~/Documents/xadf' C-m C-l

# Clear the screen, and then display current
# branch's name and status, then enter
send-keys -t xadf:1 'clear;git status -sb' C-m

In fact we can send any arbitrary commands to a tmux window with that method. Note that if we don’t include C-m, the command would just sit in the terminal as if you press those keys on the window. It is not run yet, as we haven’t send the ENTER key to execute it. The C-m sequence is equivalent to pressin ENTER. Likewise, the C-l sequence is the same as pressing Ctrl+l or running clear command.

Revisiting our fe.conf, we can then have the following to consistently open a detached named tmux session fe during a terminal start if it is not present already and have it shows the correct working directory for both windows:

# File ~/.tmux.conf.d/sessions/fe.conf
# Working directory is ~/App/fe

# Rename window :0
rename-window -t :0 'git'

# Send 'cd ~/App/fe' to window :0 and clear
send-keys -t :0 'cd ~/App/fe' C-m C-l

# Make a new named window
new-window -t :1 -n 'npm' -c ~/App/fe

# Select first window
selectw -t 0

Then we can create similar configuration files for core and deployment. We can determine how many tmux windows would each of them be, and what to name (or not) each of the tmux windows. We can also send and run arbitrary commands automatically (such as npm run dev, git pull, or even git log --oneline --graph) in the desired tmux windows.

Automatically run tmux in vscode terminal

The last part that we need to implement are step 5-6 of our pseudocode. We want to test whether we’re running under vscode or not. Surprisingly, the solution to this is fairly trivial. You can read it from StackOverflow or AskUbuntu.

Basically we want to check whether or not $TERM_PROGRAM environment variable is set to vscode. If that is true, then we are in vscode, and we can just connect to a session with the same name as our working directory using our tmx script. The implementation of those statements that can be run under bash and zsh is:

[[ "$TERM_PROGRAM" == "vscode" ]] \
    && tmx "$(basename "$PWD")" || true

The last true command is only run if we are not in vscode. I added it there because in some terminal prompt style, we get a certain feedback if the last run command gives nonzero exit status. For example in a default oh-my-zsh installation, the prompt indicator might turn from green to red the first time I run a terminal from outside of vscode. That is a minor annoyance for me, especially if that line is appended at the end of our .bashrc and/or .zshrc.

The true statement would always return exit status 0, which guarantees a clean exit status at the beginning of our interactive terminal prompt. That is, assuming that the tmx script does not terminate with a nonzero exit status.

Conclusion

To improve our developer workflows, and addresses the issues explored in this article, we implemented the following measures:

  1. We implemented a sane, minimalistic, and usable default tmux configuration in ~/.config/tmux/tmux.conf. It can be downloaded from gitlab.
  2. To allow a persistant terminal session that can be accessed from any terminal or ssh connection in our developer’s machine, we implemented a script to generate a new named tmux session on demand, or connect to an existing named tmux session if already present. The script can be obtained from gitlab. To use it, add the file to your path and make it executable.
  3. To automatically launch arbitrary amount of named tmux sessions on terminal start up if they are not run already, and load the associated tmux configuration files under ~/tmux.conf.d/sessions/, we implemented a set_tmux script. The script can be obtained from gitlab. To use it, add the file to your path and make it executable.
  4. Developers are encouraged to create their own tmux config files and place it under ~/.tmux.conf.d/sessions/ with the same name as their project directory and file extension .conf, as every developer have their own specific workflows and preferences.
  5. In ~/.bashrc or ~/.zshrc, adds the attached snippets (Appendix) to enable our setup. Modify as you see fit.

Appendix

# Add ~/.local/bin/ to $PATH if not already
# present. It should contains tmx and set_tmux
# script for our setup
test -z "$(echo $PATH|grep "$HOME/\.local/bin")" \
    && PATH="$HOME/.local/bin:$PATH"

# Change the tmux session names according to your
# needs. Also prepare custom configurations under
# ~/.tmux.conf.d/sessions/ if desired
set_tmux core fe deployment

# Check if we are under vscode. If yes, run tmux
# and/or attach to an existing tmux session
[[ "$TERM_PROGRAM" == "vscode" ]] \
    && tmx "$(basename "$PWD")" || true